Primer commit

This commit is contained in:
2026-05-30 14:31:19 -06:00
commit a35d26fac0
277 changed files with 265240 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "mp-manager",
"runtimeExecutable": "python",
"runtimeArgs": ["main.py"],
"port": 8000
}
]
}
+354
View File
@@ -0,0 +1,354 @@
{
"permissions": {
"allow": [
"Bash(findstr /R /I \"create_contact\\\\|locationId.*contact\\\\|POST.*contact\" *.py scripts\\\\*.py)",
"Bash(xargs grep -l \"create_contact\")",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\audit_contact_sync_coverage.py\")",
"Bash(python -c \"import sync_engine; accounts = sync_engine.parse_accounts_csv\\(\\); branches = [a for a in accounts if a['location_id'] != 'GbKkBpCmKu2QmloKFHy3' and 'demo' not in a['nombre'].lower\\(\\)]; print\\(f'Total branches: {len\\(branches\\)}'\\); [print\\(f' {a[\\\\\"location_id\\\\\"]} {a[\\\\\"nombre\\\\\"]}'\\) for a in branches[:8]]\")",
"Bash(python scripts/audit_contact_sync_coverage.py --location fKn9SaXZoKcjjLryg10v --max-contacts 100)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --location fKn9SaXZoKcjjLryg10v --limit 30 --max-contacts-brand 8000)",
"Bash(python -m py_compile scripts/sync_contacts_branch_to_brand.py)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --location 5qebe8IhFUjlcBvJcqCa --limit 20 --max-contacts-brand 8000)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --location fKn9SaXZoKcjjLryg10v --limit 10 --max-contacts-brand 8000)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --location fKn9SaXZoKcjjLryg10v --limit 5 --workers 2 --max-contacts-brand 8000)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --all --limit 5 --workers 3 --max-contacts-brand 8000 --max-contacts 5)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --all --limit 10 --workers 4 --max-contacts-brand 8000 --max-contacts 10 --run-id final_check_dry)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --all --limit 5 --workers 4 --max-contacts-brand 8000 --max-contacts 5 --run-id final_check_dry)",
"Bash(python -c ' *)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --apply --all --limit 1 --max-contacts-brand 100 --max-contacts 1)",
"Bash(python -m py_compile scripts/sync_contacts_branch_to_brand.py scripts/audit_contact_sync_coverage.py script_runner.py)",
"PowerShell($f = \"C:\\\\Users\\\\URIELJ~1\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\H--MegaSync-Proyectos-MP-Manager\\\\461319af-4ad2-4a89-a9fd-5373ad94647c\\\\tasks\\\\bha126a8z.output\"; Write-Output \"Total lineas: $\\(\\(Get-Content $f | Measure-Object -Line\\).Lines\\)\"; Write-Output \"---\"; Write-Output \"Sucursales procesadas:\"; \\(Select-String -Path $f -Pattern \"Procesando sucursal:\"\\).Count; Write-Output \"---\"; Write-Output \"Resumen final encontrado:\"; \\(Select-String -Path $f -Pattern \"RESUMEN DE RECONCILIACION\"\\).Count; Write-Output \"---\"; Write-Output \"Errores section encontrada:\"; \\(Select-String -Path $f -Pattern \"ERRORES Y ADVERTENCIAS\"\\).Count)",
"Bash(python scripts/audit_contact_sync_coverage.py --max-contacts 500 --json coverage_baseline.json)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --all --workers 4 --max-contacts 200 --max-contacts-brand 12000 --run-id iter2_global)",
"Bash(echo \"EXIT=$?\")",
"Bash(python scripts/sync_contacts_branch_to_brand.py --location qe3s72MRDhbEWlaFZ2Ko --max-contacts 50 --max-contacts-brand 12000 --run-id iter3_verifier)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --all --workers 4 --max-contacts 10000 --max-contacts-brand 20000 --run-id iter3_v2)",
"Bash(python scripts\\\\_diag_canal_origen_audit.py)",
"Bash(python scripts/_diag_canal_origen_audit.py)",
"Bash(python -m py_compile scripts/canal_origen_resolver.py scripts/tag_canal_origen_workflow.py scripts/fuente_prospecto_workflow.py scripts/run_origen_fuente_workflows.py)",
"Bash(python scripts/_test_resolver.py)",
"Bash(python scripts/run_origen_fuente_workflows.py --all --dry-run --contact-only)",
"Bash(tee dryrun_canal_origen_v1.log)",
"Bash(cd \"/h/MegaSync/Proyectos/MP Manager\" && wc -l dryrun_canal_origen_v1.log)",
"Bash(tee dryrun_canal_origen_v2.log)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --location 5qebe8IhFUjlcBvJcqCa --max-contacts-brand 20000 --run-id pilot_jojutla_pre_dry)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --apply --location 5qebe8IhFUjlcBvJcqCa --max-contacts-brand 20000 --run-id pilot_jojutla_apply)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --location 5qebe8IhFUjlcBvJcqCa --max-contacts-brand 20000 --run-id pilot_jojutla_post_dry)",
"Bash(awk '/^############/{step++} step==1 && /: [0-9]+ contactos actualizados/{print}' dryrun_canal_origen_v2.log)",
"Bash(python scripts/run_origen_fuente_workflows.py --all --dry-run --contact-only --sync-main)",
"Bash(tee dryrun_final.log)",
"Bash(python scripts/sync_contacts_branch_to_brand.py --all --workers 4 --max-contacts 10000 --max-contacts-brand 20000 --run-id post_bulk_dry)",
"Bash(python -m py_compile scripts/find_cross_branch_duplicates.py script_runner.py)",
"Bash(python scripts/find_cross_branch_duplicates.py --top 3 --no-brand-check)",
"Bash(python scripts/find_cross_branch_duplicates.py --top 2)",
"Bash(type requirements.txt)",
"Bash(python -c \"import openpyxl; print\\('openpyxl', openpyxl.__version__\\)\")",
"Bash(python -c \"import openpyxl; print\\(openpyxl.__version__\\)\")",
"Bash(python scripts/find_cross_branch_duplicates.py --top 0 --xlsx duplicados_test.xlsx)",
"Bash(python -c \"from openpyxl import load_workbook; wb=load_workbook\\('duplicados_test.xlsx'\\); ws=wb.active; print\\('hoja:', ws.title\\); print\\('filas:', ws.max_row\\); print\\('cols:', ws.max_column\\); print\\('headers:', [ws.cell\\(1,i\\).value for i in range\\(1,ws.max_column+1\\)]\\); print\\('fila 2:', [ws.cell\\(2,i\\).value for i in range\\(1,ws.max_column+1\\)]\\)\")",
"Bash(del duplicados_test.xlsx)",
"Bash(python -m py_compile scripts/find_cross_branch_duplicates.py)",
"Bash(python scripts/find_cross_branch_duplicates.py --top 0)",
"Bash(python -m py_compile scripts/tag_canal_origen_workflow.py)",
"Bash(ls *.xlsx *.csv)",
"Bash(ls reports *)",
"Bash(ls exports *)",
"Bash(ls scripts/*.xlsx)",
"Bash(rm -f reporte.xlsx reporte.csv exports/*.xlsx)",
"Bash(python -m py_compile scripts/find_cross_branch_duplicates.py main.py script_runner.py)",
"Bash(python scripts/find_cross_branch_duplicates.py --top 0 --xlsx)",
"Bash(tasklist)",
"Bash(wmic process *)",
"Bash(python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py)",
"Bash(node -c static/js/app.js)",
"Bash(python \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\_tmp_search_pilares.py\")",
"Bash(python)",
"Bash(python scripts/find_cross_branch_duplicates.py --json)",
"WebFetch(domain:marketplace.gohighlevel.com)",
"Bash(python -m py_compile ghl_client.py)",
"Bash(python -m py_compile scripts/sync_forms_brand.py)",
"Bash(python -m py_compile scripts/audit_brand_sucursal_vs_form.py scripts/sync_forms_brand.py)",
"Bash(python -m py_compile script_runner.py db.py ghl_client.py scripts/sync_forms_brand.py scripts/audit_brand_sucursal_vs_form.py)",
"Bash(python scripts/sync_forms_brand.py)",
"Bash(python scripts/audit_brand_sucursal_vs_form.py --show verificar)",
"Bash(python scripts/audit_brand_sucursal_vs_form.py --filter-marca queretaro --show all)",
"Bash(python scripts/audit_brand_sucursal_vs_form.py --filter-marca puebla --show all)",
"Bash(python -m py_compile scripts/audit_brand_sucursal_vs_form.py)",
"Bash(python -m py_compile db.py ghl_client.py scripts/sync_forms_brand.py scripts/audit_brand_sucursal_vs_form.py script_runner.py)",
"WebFetch(domain:highlevel.stoplight.io)",
"Bash(python scripts/sync_forms_brand.py --backfill-months 8 --quiet)",
"Bash(python -m py_compile db.py scripts/sync_forms_brand.py)",
"Bash(python scripts/sync_forms_brand.py --reextract-only)",
"Bash(python scripts/audit_brand_sucursal_vs_form.py)",
"Bash(python -m py_compile scripts/fix_brand_sucursal_from_form.py)",
"Bash(python -m py_compile script_runner.py scripts/fix_brand_sucursal_from_form.py)",
"Bash(python scripts/fix_brand_sucursal_from_form.py)",
"Bash(python _temp_conflict_list.py)",
"Bash(python _temp_create_in_target.py)",
"Bash(python _temp_compare_opps.py)",
"Bash(rm _temp_compare_opps.py)",
"Bash(python _temp_delete_qro_and_validate.py)",
"Bash(python scripts/audit_brand_contact_opportunity_map.py)",
"Bash(python scripts/audit_brand_contact_opportunity_map.py --show-no-opp --limit 50)",
"Bash(python scripts/audit_brand_contact_opportunity_map.py --show-multi-opp)",
"Bash(python -m py_compile scripts/audit_brand_vs_branches_discrepancy.py script_runner.py)",
"Bash(python scripts/audit_brand_vs_branches_discrepancy.py)",
"Bash(python scripts/audit_brand_vs_branches_discrepancy.py --show-sync-gaps --show-multi-opp-gaps)",
"Bash(python scripts\\\\compare_pipelines_stages.py --reference-main --live --show-ids)",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\fill_sucursal_tienda_from_location.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\script_runner.py\")",
"Bash(Get-ChildItem \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\" -Filter \"*.py\")",
"Bash(Measure-Object)",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\tag_canal_origen_workflow.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\common.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\fill_sucursal_tienda_from_location.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\sync_contact_sucursal_to_opportunity.py\")",
"Bash(python scripts/run_origen_fuente_workflows.py --location XkduzafvwsrWcEFg6Qlj --dry-run)",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\tag_canal_origen_workflow.py\")",
"WebSearch",
"Bash(curl -sL \"https://marketplace.gohighlevel.com/docs/ghl/custom-fields/custom-fields-v-2-api/\" -A \"Mozilla/5.0\")",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\tag_canal_origen_workflow.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\common.py\")",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\audit_custom_fields_schema.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\script_runner.py\")",
"Bash(python scripts/audit_custom_fields_schema.py --location XkduzafvwsrWcEFg6Qlj)",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\audit_custom_fields_schema.py\")",
"Bash(python scripts/audit_custom_fields_schema.py --all)",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\migrate_branch_fieldkeys.py\")",
"Bash(python scripts/migrate_branch_fieldkeys.py --location Vf7qQl3L9vakJ8hDtQ8e)",
"Bash(python scripts/migrate_branch_fieldkeys.py --location Vf7qQl3L9vakJ8hDtQ8e --field opportunity.modalidad_del_empeno --apply)",
"Bash(python scripts/migrate_branch_fieldkeys.py --location XkduzafvwsrWcEFg6Qlj --apply)",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m pip show playwright)",
"Bash(python scripts/migrate_branch_fieldkeys.py --all --apply)",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py\")",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\main.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_session_generator.py\")",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\merge_orphan_to_clean.py\")",
"Bash(python scripts/merge_orphan_to_clean.py)",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -X utf8 -u \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py\" --action toggle-status --location Z64WQKORPVwXb5mn68Ef --workflow-id 67f98059-d82b-4698-b82a-8f8874a9b263 --current-status published)",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -X utf8 -u \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py\" --action toggle-status --location Z64WQKORPVwXb5mn68Ef --workflow-id 67f98059-d82b-4698-b82a-8f8874a9b263 --current-status draft)",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\fix_cancun_uruapan_residual.py\")",
"Bash(python scripts/fix_cancun_uruapan_residual.py)",
"Bash(python scripts/fix_cancun_uruapan_residual.py --apply)",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_session_generator.py\")",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\fix_cancun_modalidad.py\")",
"Bash(python scripts/fix_cancun_modalidad.py)",
"Bash(python scripts/fix_cancun_modalidad.py --apply)",
"Bash(python scripts/migrate_branch_fieldkeys.py --batch Vf7qQl3L9vakJ8hDtQ8e,Z64WQKORPVwXb5mn68Ef --apply)",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -X utf8 -u \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py\" --action bulk-draft --batch-file \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\_test_batch.json\")",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\main.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py\")",
"Bash(rm -f \"H:/MegaSync/Proyectos/MP Manager/_bulk_batch_\"*.json \"H:/MegaSync/Proyectos/MP Manager/_test_batch.json\" 2>&1 && echo OK)",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\email_otp_reader.py\" \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_session_generator.py\")",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m pip install imap-tools)",
"Bash(node --check \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\static\\\\js\\\\app.js\")",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_session_generator.py\")",
"Bash(\"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Local\\\\Programs\\\\Python\\\\Python312\\\\python.exe\" -X utf8 -u \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_session_generator.py\")",
"Bash(mkdir -p logs/manual)",
"Bash(python scripts/reconcile_and_sync_opportunities.py --dry-run --location Z64WQKORPVwXb5mn68Ef)",
"Bash(echo \"Return code: $?\")",
"Bash(python -m py_compile scripts/audit_brand_vs_branches_totals.py)",
"Bash(python scripts/audit_brand_vs_branches_totals.py)",
"Bash(python -m py_compile main.py)",
"Bash(python scripts/dedupe_branch_pipelines.py --location IE7ci6Ddfk1WvQabEa4q --live)",
"Bash(python -m py_compile scripts/dedupe_branch_pipelines.py)",
"Bash(python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py scripts/audit_brand_vs_branches_totals.py)",
"Bash(python -c \"from scripts import audit_brand_vs_branches_totals as a; data = a.run_audit\\(limit_missing=5\\); print\\('totals:', data['totals']\\); print\\('demos_excluded:', data['demos_excluded']\\); print\\('per_branch count:', len\\(data['per_branch']\\)\\); print\\('missing buckets:', {k: v['total'] for k, v in data['missing'].items\\(\\)}\\)\")",
"Bash(python main.py)",
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://127.0.0.1:8000/api/comparativa/marca-vs-sucursales)",
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" \"http://127.0.0.1:8000/api/comparativa/marca-vs-sucursales/export?bucket=invalido\")",
"Bash(curl -s \"http://127.0.0.1:8000/api/scripts\")",
"Bash(python -c \"import sys, json; scripts=json.load\\(sys.stdin\\); match=[s for s in scripts.get\\('scripts', scripts\\) if 'audit_brand_vs_branches_totals' in \\(s.get\\('name'\\) or ''\\)]; print\\('found:', match[0] if match else 'NOT FOUND'\\)\")",
"Bash(python -c \"import sys, json; scripts=json.load\\(sys.stdin\\); match=[s for s in scripts if 'audit_brand_vs_branches_totals' in \\(s.get\\('name'\\) or ''\\)]; print\\('found:', match[0]['title'] if match else 'NOT FOUND', '| category:', match[0]['category'] if match else ''\\)\")",
"Bash(cmd /c stop.bat)",
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" --max-time 2 http://127.0.0.1:8000/)",
"PowerShell($conns = Get-NetTCPConnection -LocalPort 8000 -State Listen -ErrorAction SilentlyContinue; foreach \\($c in $conns\\) { try { Stop-Process -Id $c.OwningProcess -Force -ErrorAction Stop; \"Killed PID $\\($c.OwningProcess\\)\" } catch { \"Failed: $_\" } })",
"PowerShell(Get-Process python -ErrorAction SilentlyContinue | Where-Object { $_.StartTime -gt \\(Get-Date\\).AddMinutes\\(-15\\) } | ForEach-Object { \"PID $\\($_.Id\\) Started $\\($_.StartTime\\) Cmd: $\\(\\($_.CommandLine | Out-String\\).Trim\\(\\)\\)\"; Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue })",
"PowerShell(Get-WmiObject Win32_Process -Filter \"Name='python.exe'\")",
"Bash(python -m py_compile sync_engine.py)",
"Bash(python -c \"import sync_engine; print\\('Workers default:', sync_engine.get_sync_max_workers\\(\\)\\)\")",
"PowerShell(\"$env:SYNC_ENGINE_MAX_WORKERS = '$env:SYNC_ENGINE_MAX_WORKERS'\"; [System.Environment]::GetEnvironmentVariable\\('SYNC_ENGINE_MAX_WORKERS', 'User'\\); [System.Environment]::GetEnvironmentVariable\\('SYNC_ENGINE_MAX_WORKERS', 'Machine'\\))",
"Bash(python -c \"import sync_engine; accs = sync_engine.parse_accounts_csv\\(\\); print\\('Total cuentas en el CSV:', len\\(accs\\)\\)\")",
"PowerShell([System.Environment]::SetEnvironmentVariable\\('SYNC_ENGINE_MAX_WORKERS', '1000', 'User'\\); [System.Environment]::GetEnvironmentVariable\\('SYNC_ENGINE_MAX_WORKERS', 'User'\\))",
"Bash(python -m py_compile scripts/sync_missing_opps_to_brand.py)",
"Bash(python scripts/sync_missing_opps_to_brand.py --yes)",
"Bash(curl -s -X POST http://127.0.0.1:8000/api/comparativa/sync-missing-opps -H \"Content-Type: application/json\" -d '{\"dry_run\": true}')",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('dry_run:', d['dry_run']\\); print\\('summary:', d['summary']\\); print\\('items count:', len\\(d['items']\\)\\); print\\('first item:', json.dumps\\(d['items'][0], indent=2, ensure_ascii=False\\) if d['items'] else 'none'\\)\")",
"mcp__Claude_Preview__preview_start",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('summary:', d['summary']\\); print\\('items still in bucket:'\\); [print\\(' -', i['opp_id'], i['opp_name'], i['branch_name'], 'status:', i['status']\\) for i in d['items']]\")",
"Bash(python scripts/sync_missing_opps_to_brand.py --apply --yes --only-opp pauvJPfmwXHxiT7rBaxb)",
"Bash(curl -s http://127.0.0.1:8000/api/comparativa/sync-missing-opps -X POST -H \"Content-Type: application/json\" -d '{\"dry_run\":true}' --max-time 2 -o /dev/null -w \"%{http_code}\\\\n\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" --max-time 2 http://127.0.0.1:8000/)",
"Bash(curl -s -X POST http://127.0.0.1:8000/api/comparativa/sync-missing-opps -H \"Content-Type: application/json\" -d '{\"dry_run\": false, \"yes\": true}' --max-time 120)",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('summary:', d['summary']\\); print\\('run_id:', d.get\\('run_id'\\)\\); print\\('items con error:'\\); [print\\(' -', i['opp_id'], i['opp_name'], '|', i.get\\('error'\\) or 'OK', '|', i['status']\\) for i in d['items']]\")",
"Bash(curl -s http://127.0.0.1:8000/api/comparativa/marca-vs-sucursales -o /dev/null -w \"%{http_code}\" --max-time 2)",
"Bash(curl -s -X POST http://127.0.0.1:8000/api/scripts/runs --max-time 2 -o /dev/null -w \"%{http_code}\")",
"Bash(python -m py_compile scripts/sync_missing_contacts_to_brand.py)",
"Bash(python scripts/sync_missing_contacts_to_brand.py --yes)",
"Bash(curl -s -X POST http://127.0.0.1:8000/api/comparativa/sync-missing-contacts -H \"Content-Type: application/json\" -d '{\"dry_run\": true}' --max-time 60)",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('summary:', d['summary']\\); print\\('first 3 items:'\\); [print\\(' -', i['name'], '|', i['status'], '|', i.get\\('actions', [{}]\\)[0].get\\('strategy', ''\\)\\) for i in d['items'][:3]]\")",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" --max-time 2 http://127.0.0.1:8000/api/comparativa/marca-vs-sucursales)",
"Bash(python -m py_compile scripts/sync_missing_opps_to_brand.py scripts/sync_missing_contacts_to_brand.py)",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('summary:', d['summary']\\); print\\('first 4 items:'\\); [print\\(' -', i['name'], '|', i['status'], '|', 'phone:', i.get\\('phone'\\) or '\\(empty\\)', '| email:', i.get\\('email'\\) or '\\(empty\\)'\\) for i in d['items'][:4]]\")",
"Bash(curl -s -X POST http://127.0.0.1:8000/api/comparativa/sync-missing-opps -H \"Content-Type: application/json\" -d '{\"dry_run\": true}' --max-time 60)",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('summary:', d['summary']\\); print\\('strategies usadas:'\\); from collections import Counter; print\\(Counter\\(a.get\\('strategy','none'\\) for i in d['items'] for a in i['actions'] if a.get\\('action'\\)=='match_existing_contact'\\)\\); print\\('first 4 items:'\\); [print\\(' -', i['opp_name'], '|', i['status'], '|', 'phone:', i.get\\('branch_contact',{}\\).get\\('phone'\\) or '\\(empty\\)'\\) for i in d['items'][:4]]\")",
"Bash(curl -s -X POST http://127.0.0.1:8000/api/comparativa/sync-missing-opps -H \"Content-Type: application/json\" -d '{\"dry_run\": false, \"yes\": true, \"opp_ids\": [\"pauvJPfmwXHxiT7rBaxb\"]}' --max-time 60)",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('summary:', d['summary']\\); print\\('run_id:', d.get\\('run_id'\\)\\); item = d['items'][0]; print\\('item status:', item['status']\\); print\\('actions:'\\); [print\\(' -', a\\) for a in item['actions']]\")",
"Bash(python -c \"import sys, json; d=json.load\\(sys.stdin\\); print\\('Bucket missing_opps_in_brand now has:', d['summary']['candidates'], 'candidatas \\(era 11 antes\\)'\\)\")",
"Bash(python -m py_compile scripts/sync_brand_to_branch_contacts.py)",
"Bash(python scripts/sync_brand_to_branch_contacts.py --yes)",
"Bash(curl -s \"http://127.0.0.1:8000/api/comparativa/marca-vs-sucursales?limit_missing=2000\")",
"Bash(python -m py_compile main.py scripts/audit_brand_vs_branches_totals.py)",
"Bash(python -m py_compile main.py scripts/create_opportunities_for_contacts_without_any.py db.py)",
"PowerShell(cmd /c restart.bat 2>&1)",
"PowerShell($ok = $false; foreach \\($i in 1..15\\) { try { $r = Invoke-WebRequest -Uri 'http://127.0.0.1:8000/api/contacts/NSDniGzjxotVDNa5YxqW?limit=5' -UseBasicParsing -TimeoutSec 2; if \\($r.StatusCode -eq 200\\) { $ok = $true; break } } catch { Start-Sleep -Milliseconds 800 } }; if \\($ok\\) { \"READY\"; $r.Content.Substring\\(0, [Math]::Min\\(1500, $r.Content.Length\\)\\) } else { \"STILL DOWN\" })",
"PowerShell($r = Invoke-WebRequest -Uri 'http://127.0.0.1:8000/api/contacts/NSDniGzjxotVDNa5YxqW?limit=5' -UseBasicParsing; $j = $r.Content | ConvertFrom-Json; \"total: $\\($j.total\\) | test_count: $\\($j.test_count\\)\"; foreach \\($c in $j.contacts\\) { \" - $\\($c.first_name\\) $\\($c.last_name\\) | is_test=$\\($c.is_test\\) | reasons=$\\($c.test_reasons -join '; '\\)\" })",
"Bash(python -m py_compile scripts/fix_brand_tienda_from_sucursal.py)",
"Bash(python -m py_compile main.py scripts/fix_brand_tienda_from_sucursal.py scripts/audit_brand_vs_branches_totals.py)",
"Bash(python -m py_compile main.py script_runner.py)",
"Bash(python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py scripts/fix_brand_tienda_from_sucursal.py scripts/audit_brand_vs_branches_totals.py)",
"Bash(python -m py_compile main.py contact_classifier.py scripts/find_test_contacts.py)",
"Bash(node --check static/js/app.js)",
"Bash(python -m py_compile main.py scripts/dedupe_branch_pipelines.py)",
"Bash(python -m py_compile main.py sync_engine.py ghl_client.py scripts/sync_missing_opps_to_brand.py)",
"Bash(python -m py_compile main.py sync_engine.py scripts/sync_missing_opps_to_brand.py scripts/sync_missing_contacts_to_brand.py scripts/sync_brand_to_branch_contacts.py scripts/create_opportunities_for_contacts_without_any.py)",
"Bash(python -m py_compile main.py sync_engine.py scripts/sync_missing_opps_to_brand.py)",
"PowerShell(python _debug_match.py)",
"Bash(python -m py_compile contact_classifier.py scripts/find_test_contacts.py)",
"Bash(python -c \"from contact_classifier import classify_contact, TEST_EMAILS; print\\('TEST_EMAILS:', sorted\\(TEST_EMAILS\\)\\); print\\('servando ->', classify_contact\\('Juan','Perez','servandobra@gmail.com',[]\\)\\); print\\('uriel ->', classify_contact\\('Ana','Lopez','uriel.conse3@gmail.com',[]\\)\\); print\\('otro ->', classify_contact\\('Mario','Bros','mario@example.com',[]\\)\\)\")",
"Bash(python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py contact_classifier.py)",
"Bash(python -m py_compile scripts/audit_brand_vs_branches_totals.py main.py db.py ghl_client.py sync_engine.py script_runner.py contact_classifier.py)",
"Bash(python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py contact_classifier.py scripts/audit_brand_vs_branches_totals.py)",
"Bash(python -m py_compile scripts/cleanup_puebla_qro_duplicates.py)",
"Bash(python scripts/cleanup_puebla_qro_duplicates.py)",
"Bash(python scripts/cleanup_puebla_qro_duplicates.py --apply --yes)",
"Bash(python scripts/cleanup_cross_branch_duplicates.py --exclude-contact RwxMQr0Ywvydjr3veCYo)",
"PowerShell(Get-Process -Id 16852)",
"Bash(Get-ChildItem -Path \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\" -Recurse -Force)",
"Bash(Select-Object FullName)",
"Bash(Get-Content -Path \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\main.py\" -TotalCount 2400)",
"Bash(Select-Object -Last 600)",
"Bash(Get-ChildItem -Path \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\migrations\" -ErrorAction SilentlyContinue)",
"Bash(Select-Object -ExpandProperty Name)",
"Bash(cmd.exe /c stop.bat)",
"Bash(python -c \"import paths; print\\('OK'\\); import os; [print\\(' ', d, os.path.exists\\(d\\)\\) for d in paths._ALL_DIRS]\")",
"Bash(python -m py_compile paths.py db.py script_audit.py error_logging.py main.py)",
"Bash(python -m py_compile paths.py db.py script_audit.py error_logging.py main.py script_runner.py sync_engine.py ghl_client.py contact_classifier.py create_skeletons.py)",
"Bash(curl -s -f http://127.0.0.1:8000/ -o /dev/null)",
"Bash(curl -s http://127.0.0.1:8000/api/branches)",
"Bash(python -c \"import sys, json; data=json.load\\(sys.stdin\\); print\\(f'branches: {len\\(data\\)}'\\); [print\\(f' {b[\\\\\"location_id\\\\\"][:8]}... {b[\\\\\"nombre\\\\\"]}'\\) for b in data[:3]]\")",
"Bash(curl -s -w \"\\\\nHTTP %{http_code}\\\\n\" http://127.0.0.1:8000/api/exports/__refactor_smoke_test.txt)",
"Bash(curl -s -o /dev/null -w \"HTTP %{http_code}\\\\n\" \"http://127.0.0.1:8000/api/this-does-not-exist\")",
"Bash(python -c \"import json; d=json.load\\(open\\('generated/runtime/server_info.json'\\)\\); print\\('PID:', d['pid'], 'PORT:', d['port']\\)\")",
"Bash(cmd.exe /c \"stop.bat\")",
"Bash(cmd.exe /c start.bat)",
"Bash(curl -s -f http://127.0.0.1:8000/api/branches -o /tmp/branches.json)",
"Bash(python -c \"import json; print\\(len\\(json.load\\(open\\('/tmp/branches.json'\\)\\)\\)\\)\")",
"Bash(python runtime_control.py preflight)",
"Bash(python runtime_control.py wait-ready --timeout=30)",
"Bash(powershell -NoProfile -Command \"\\(Get-CimInstance Win32_Process -Filter 'ProcessId=27092'\\).CommandLine\")",
"Bash(python runtime_control.py status)",
"Bash(python -m py_compile main.py scripts/ghl_browser_workflow_manager.py)",
"Bash(python -m py_compile scripts/common.py scripts/find_cross_branch_duplicates.py)",
"Bash(python scripts/find_cross_branch_duplicates.py --xlsx duplicados_audit.xlsx --json duplicados_audit.json --top 5)",
"Bash(echo \"--- exit: $? ---\")",
"Bash(python -m py_compile \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\find_cross_branch_duplicates.py\")",
"Bash(python runtime_control.py wait-ready --timeout=15)",
"Bash(curl -s -w 'HTTP %{http_code}\\\\n' http://127.0.0.1:__TRACKED_VAR__/api/exports/_smoke.txt)",
"Bash(powershell -Command \"Select-String -Path 'H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py' -Pattern 'def.*workflow|for.*workflow|iterate' -Context 2 | head -80\")",
"Bash(powershell -Command \"Select-String -Path 'H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\scripts\\\\ghl_browser_workflow_manager.py' -Pattern 'def.*bulk|for.*items|def.*main' | head -20\")",
"Bash(Get-ChildItem -Path \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\" -Force)",
"Bash(Select-Object -First 20)",
"Bash(python scripts/ghl_browser_inspect_errors_panel.py --location uZnMH5bO6MXTHcgHeyq9 --workflow-id 346e764e-95c5-4920-ae49-e64bdf556972)",
"Bash(python scripts/ghl_browser_workflow_anomaly_scanner.py --location uZnMH5bO6MXTHcgHeyq9 --workflow-id 346e764e-95c5-4920-ae49-e64bdf556972)",
"Bash(python -u -c ' *)",
"Bash(python -m py_compile scripts/audit_custom_fields_cross_object.py scripts/audit_custom_fields_schema.py)",
"Bash(python scripts/audit_custom_fields_schema.py --all --object both)",
"Bash(python scripts/fix_opportunity_picklist_alignment.py --location fKn9SaXZoKcjjLryg10v --field \"CANAL DE ORIGEN\" --apply --run-id picklist-canal-63ee67c6b952)",
"Bash(python -m py_compile main.py script_runner.py db.py ghl_client.py sync_engine.py paths.py script_audit.py)",
"Bash(node -c static/js/live_terminal.js)",
"Bash(findstr /C:\"records_updated=\" /C:\"PUT schema fall\" /C:\"✗\" /C:\"ERROR\" /C:\"falló\")",
"Bash(python scripts/fix_opportunity_picklist_alignment.py --location GbKkBpCmKu2QmloKFHy3 --field \"Canal de Origen \\(contact rename\\)\")",
"Bash(python scripts/fix_opportunity_picklist_alignment.py --location fKn9SaXZoKcjjLryg10v --field \"Lead Descartado\")",
"Bash(python scripts/fix_opportunity_picklist_alignment.py --all --field \"Lead Descartado\" --apply --run-id picklist-ld-rollout-5e11be2c7c)",
"Bash(curl -sS -o /tmp/anom_test.xlsx -w \"HTTP %{http_code}\\\\nbytes=%{size_download}\\\\ntype=%{content_type}\\\\n\" \"http://127.0.0.1:8001/api/workflows/anomaly-report-xlsx/workflow_anomalies_20260526_100617.csv\")",
"Bash(python -m py_compile main.py script_runner.py scripts/ghl_browser_workflow_anomaly_scanner.py paths.py)",
"Bash(curl -sS -o /tmp/anom_v2.xlsx -w \"HTTP %{http_code}\\\\n\" \"http://127.0.0.1:8001/api/workflows/anomaly-report-xlsx/workflow_anomalies_20260526_104238.csv\")",
"Bash(curl -sS -o /tmp/anom_v2.xlsx -w \"HTTP %{http_code} bytes=%{size_download}\\\\n\" \"http://127.0.0.1:8001/api/workflows/anomaly-report-xlsx/workflow_anomalies_20260526_104238.csv\")",
"Bash(curl -sS -o \"generated/_test_anom_v2.xlsx\" -w \"HTTP %{http_code} bytes=%{size_download}\\\\n\" \"http://127.0.0.1:8001/api/workflows/anomaly-report-xlsx/workflow_anomalies_20260526_104238.csv\")",
"PowerShell($env:NODE_PATH = \"C:\\\\Users\\\\Uriel Jareth\\\\AppData\\\\Roaming\\\\npm\\\\node_modules\"; node \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\\\\generated\\\\runtime\\\\gen_cronograma_sla.js\")",
"Bash(python scripts/fix_opportunity_picklist_alignment.py --location GbKkBpCmKu2QmloKFHy3 --field \"CANAL DE ORIGEN\")",
"Bash(curl -s -o response.txt -w \"HTTP %{http_code}\\\\nTIME %{time_total}\\\\n\" \"http://127.0.0.1:8000/api/comparativa/marca-vs-sucursales?limit_missing=2000\" --max-time 60)",
"Bash(python scripts/audit_tags_across_accounts.py --json generated/reports/tags_audit.json)",
"Bash(python -c \"import os; p=r'generated/runtime/dryrun_source_fix.log'; print\\('size:', os.path.getsize\\(p\\), 'bytes'\\)\")",
"Bash(python scripts/cleanup_and_unify_tags.py --apply --yes --json generated/reports/tag_cleanup_apply.json)",
"Bash(python scripts/sync_missing_opps_to_brand.py --dry-run --json generated/reports/missing_opps_dryrun.json)",
"Bash(awk '/### Get Custom Fields/,/### \\(Create Custom Field|Get Custom Field$\\)/' categories/locations-api.md)",
"Bash(PYTHONUTF8=1 python \"generated/_backfill_preview.py\")",
"Bash(python -m py_compile scripts/backfill_opp_sucursal_link.py)",
"Bash(PYTHONUTF8=1 python scripts/backfill_opp_sucursal_link.py --apply-links --limit 20)",
"Bash(PYTHONUTF8=1 python -c ' *)",
"Bash(PYTHONUTF8=1 python scripts/backfill_opp_sucursal_link.py --apply-links)",
"Bash(PYTHONUTF8=1 python \"generated/_classify_creates.py\")",
"Bash(awk '/## POST \\\\/opportunities\\\\/$/,/## \\(GET|PUT|DELETE\\) \\\\/opportunities/' categories/opportunities-api.md)",
"Bash(grep -iA2 -E 'Request Body|customFields|field_value|status|source|tags|contactId|required|name|pipelineStageId')",
"Bash(python scripts\\\\audit_custom_fields_full_from_db.py)",
"Bash(python scripts/audit_custom_fields_full_from_db.py)",
"Bash(python -c \"import os; from paths import DB_PATH; print\\('DB:', DB_PATH\\); print\\('exists:', os.path.exists\\(DB_PATH\\)\\); import time; print\\('mtime:', time.ctime\\(os.path.getmtime\\(DB_PATH\\)\\)\\)\")",
"Bash(python scripts/audit_brand_vs_branches_totals.py --json)",
"Bash(set PYTHONIOENCODING=utf-8)",
"Bash(python scripts/sync_missing_contacts_to_brand.py)",
"Bash(python -m py_compile scripts/apply_custom_field_layout.py)",
"Bash(python scripts/apply_custom_field_layout.py --location blRZ21GlzgUCA7bl2uVw)",
"Bash(python -c \"import sys; sys.path.insert\\(0,'scripts'\\); import audit_collision_submissions as a; print\\('brand_ok=', bool\\(a._load_token\\('GbKkBpCmKu2QmloKFHy3'\\)\\)\\); print\\('pilares_ok=', bool\\(a._load_token\\('uZnMH5bO6MXTHcgHeyq9'\\)\\)\\); print\\('map_size=', len\\(a._TOKENS_MAP\\)\\)\")",
"Bash(python generated/_investiga_alejandra.py)",
"Bash(python generated/_investiga_alejandra2.py)",
"Bash(python -m py_compile scripts/fill_opp_id_oportunidad_sucursal.py)",
"Bash(python scripts/fill_opp_id_oportunidad_sucursal.py --location Z64WQKORPVwXb5mn68Ef)",
"Bash(python -m py_compile scripts/reconcile_and_sync_opportunities.py)",
"Bash(python -m py_compile scripts/cleanup_brand_orphan_opportunities.py scripts/reconcile_and_sync_opportunities.py)",
"Bash(python scripts/cleanup_brand_orphan_opportunities.py --dry-run)",
"Bash(python generated/_investiga_alejandra3.py)",
"Read(//c/Users/Uriel Jareth/AppData/Roaming/Claude/local-agent-mode-sessions/skills-plugin/**)",
"Bash(python generated/_tmp_verificar_fechas.py)",
"Bash(python \"n8n/_add_automap_node.py\")",
"Bash(cmd /c \"stop.bat\")",
"Bash(python -m py_compile scripts/fill_contact_id_sucursal.py)",
"Bash(python scripts/fill_contact_id_sucursal.py --location Z64WQKORPVwXb5mn68Ef)",
"Bash(python scripts/fill_contact_id_sucursal.py --location blRZ21GlzgUCA7bl2uVw)",
"Bash(python scripts/audit_brand_vs_branches_totals.py --show-missing --limit-missing 200 --json)",
"Bash(tee generated\\\\reports\\\\audit_brand_diff_20260527.json)",
"Bash(python -m py_compile scripts/delete_intra_brand_duplicates.py)",
"Bash(python scripts/delete_intra_brand_duplicates.py)",
"Bash(python -c \"import uuid; print\\(uuid.uuid4\\(\\)\\)\")",
"Bash(python scripts/backfill_brand_contact_id_sucursal.py --apply --run-id 174ce44a-52bc-4ef6-b4af-b61901094a37)",
"Bash(sqlite3 generated/data/mp_manager.sqlite \"SELECT COUNT\\(*\\) as contact_count FROM contacts WHERE location_id='7H91g95hhLKwIUqSk0Rg';\")",
"Bash(python n8n/_apply_phase1.py --apply --activate)",
"Bash(python scripts/n8n_e2e_test.py --scenario 1.1)",
"Bash(python n8n/_apply_phase1_fix_phone_refs.py --apply)",
"Bash(python scripts/n8n_e2e_test.py --scenario all-phase1)",
"Bash(python scripts/n8n_e2e_test.py --scenario all-phase2)",
"Bash(python n8n/_apply_phase3.py --apply --activate)",
"Bash(python scripts/n8n_e2e_test.py --scenario 3.2)",
"Bash(python n8n/_apply_phase4.py --apply --activate)",
"Bash(python n8n/_apply_phase2_fix_code_ref.py --apply)",
"Bash(python scripts/n8n_e2e_test.py --scenario all)",
"Bash(python -m py_compile main.py db.py)",
"Bash(PYTHONIOENCODING=utf-8 python -c ' *)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_add_fallback_autoenlace_contacto.py)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_add_fallback_autoenlace_contacto.py --apply)",
"Bash(PYTHONIOENCODING=utf-8 python scripts/n8n_e2e_test.py --help)",
"Bash(PYTHONIOENCODING=utf-8 python scripts/n8n_e2e_test.py --scenario all-phase1)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_test_fallback_branch_false.py)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_add_fallback_post_update_marca.py)",
"Bash(PYTHONIOENCODING=utf-8 python scripts/n8n_e2e_test.py --scenario all-phase2)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_fix_cascada_token_mail.py)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_test_fallback_post_update.py)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_fix_cascada_locationid_mail.py)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_fix_cascada_locationid_mail.py --apply)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_fix_cascada_if_zero_results.py)",
"Bash(PYTHONIOENCODING=utf-8 python n8n/_fix_cascada_if_zero_results.py --apply)",
"Bash(find \"H:\\\\MegaSync\\\\Proyectos\\\\MP Manager\" -name \"app.js\" -o -name \"index.html\" 2>nul | head -5)",
"Bash(python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py paths.py scripts/backfill_opp_sucursal_link.py)",
"Bash(python scripts/backfill_opp_sucursal_link.py)",
"mcp__mp-manager__search_contacts",
"Bash(python -c \"import ast; ast.parse\\(open\\('main.py'\\).read\\(\\)\\); ast.parse\\(open\\('scripts/backfill_opp_sucursal_link.py'\\).read\\(\\)\\); print\\('python AST OK'\\)\")",
"Bash(python -c \"import ast; ast.parse\\(open\\('main.py', encoding='utf-8'\\).read\\(\\)\\); ast.parse\\(open\\('scripts/backfill_opp_sucursal_link.py', encoding='utf-8'\\).read\\(\\)\\); print\\('python AST OK'\\)\")",
"Bash(where node *)",
"mcp__mp-manager__get_contact",
"mcp__mp-manager__get_opportunities"
]
}
}
+39
View File
@@ -0,0 +1,39 @@
# MP Manager - Configuración local sensible
# Copia este archivo a `.env` (sin ".example") y rellena con tus valores reales.
# El archivo `.env` debe estar EXCLUIDO de MegaSync, Git, OneDrive, etc.
# Está cubierto por `.megaignore` y `.gitignore` automáticamente.
# ----------------------------------------------------------------------
# Bucéfalo CRM — credenciales para auto-login de Playwright
# Si dejas estas líneas vacías o ausentes, el session generator pedirá
# que completes el login manualmente en la ventana del navegador.
# ----------------------------------------------------------------------
BUCEFALO_LOGIN_EMAIL=
BUCEFALO_LOGIN_PASSWORD=
# ----------------------------------------------------------------------
# Correo electrónico — para leer el código MFA por IMAP
# El código de 2FA llega de noreply@donotreply.acct-mgmt.com con
# asunto/cuerpo conteniendo "is XXXXXX" (6 dígitos).
# Si estas líneas están vacías, la lectura automática del OTP no corre y
# tendrás que escribir el código manualmente.
# ----------------------------------------------------------------------
# Ejemplos comunes:
# - SiteGround: mail.tudominio.com (puerto 993, IMAPS)
# - Otro hosting compartido: el FQDN del servidor (a veces algo como cXXXX.sgvps.net)
# - Gmail: imap.gmail.com (requiere App Password, no la contraseña normal)
EMAIL_IMAP_HOST=mail.tudominio.com
EMAIL_IMAP_PORT=993
EMAIL_IMAP_USER=
EMAIL_IMAP_PASSWORD=
# Folder donde IMAP busca el correo del OTP (típicamente "INBOX"):
EMAIL_IMAP_FOLDER=INBOX
# ----------------------------------------------------------------------
# Comportamiento opcional
# ----------------------------------------------------------------------
# Segundos máximos a esperar el correo OTP tras pedir el código:
# Hemos visto que el correo de Bucéfalo tarda 20-30 s en llegar; 180 da margen amplio.
OTP_TIMEOUT_SECONDS=180
# Intervalo entre intentos de leer el INBOX (s). 3-5 está bien (no más rápido para no estresar IMAP).
OTP_POLL_INTERVAL_SECONDS=3
+15
View File
@@ -0,0 +1,15 @@
# Credenciales locales
.env
# Caché de Python
__pycache__/
*.pyc
*.pyo
# Todo lo que generan la app y los scripts vive bajo generated/ (ver paths.py)
generated/
# IDE
.vscode/
.idea/
*.swp
+3
View File
@@ -0,0 +1,3 @@
{
"$schema": "https://app.kilo.ai/config.json"
}
+9
View File
@@ -0,0 +1,9 @@
{
"mcpServers": {
"mp-manager": {
"command": "python",
"args": ["-m", "mcp_server"],
"cwd": "."
}
}
}
+21
View File
@@ -0,0 +1,21 @@
# Exclusiones para MegaSync. Las rutas son relativas a la raíz sincronizada.
# Si MegaSync no respeta este archivo en tu versión, configúralo manualmente
# desde la app: clic derecho sobre la carpeta del proyecto → Excluir.
# Credenciales sensibles
.env
# Caché de Python
__pycache__/
*.pyc
# Todo lo que generan la app y los scripts vive bajo generated/ (ver paths.py).
# Excluirlo evita conflictos de sincronización entre máquinas (DB con WAL,
# perfil de Chromium con locks, screenshots que crecen sin parar, etc).
# Si necesitas sincronizar reports/exports específicos, copíalos manualmente.
generated/
# IDE / editor
.vscode/
.idea/
*.swp
+1
View File
@@ -0,0 +1 @@
cdo-pilot-f667468708
+90
View File
@@ -0,0 +1,90 @@
# AGENTS.md
> **Capa agentica (MCP)**: para uso desde Claude Code u otros clientes LLM, este repo expone un servidor MCP stdio en `mcp_server/` con tools tipadas (defaults dry-run, `confirm_token` para aplicar, rollback vía `script_audit`). Ver [docs/AGENT_TOOLS.md](docs/AGENT_TOOLS.md) para el catálogo y recetas. Manifest navegable en `generated/agent/tools_manifest.json`.
## Commands
- Install deps with `python -m pip install -r requirements.txt`.
- Run the local app with `python main.py`; it serves FastAPI/Uvicorn at `http://127.0.0.1:8000` with reload enabled.
- On Windows, `start.bat` runs `python main.py` in a new window and opens the browser; `stop.bat` kills any process using port `8000`.
- There is no test, lint, or formatter config in this repo. For a syntax-only check, run `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py` and add specific scripts as needed.
- Run focused utility scripts directly, for example `python scripts\mp_contact_search.py <query>` or `python scripts\mp_opportunity_search.py <query-or-status>`.
- Global dashboard sync parallelism is controlled with `SYNC_ENGINE_MAX_WORKERS`; default is `12`, hard maximum is `20`. It affects the `Sincronizar Todo` button and processes multiple GHL locations in parallel.
- Dashboard batch parallelism is controlled globally with `SCRIPT_RUNNER_MAX_WORKERS`; default is `4`, hard maximum is `20`. Higher values can increase GHL `429`/timeout risk because script subprocesses do not share in-memory rate-limit state.
## App Wiring
- `main.py` is the app entrypoint; API routes call `db.py`, `sync_engine.py`, and `script_runner.py`.
- `templates/index.html` plus `static/js/app.js` and `static/css/style.css` are the single-page dashboard UI.
- `db.py` owns the local SQLite schema at `generated/data/mp_manager.sqlite` (path comes from `paths.DB_PATH`); sync writes replace per-location contacts, pipelines, and opportunities inside transactions.
- `sync_engine.py` reads `Bucéfalo - Mesa de control - API Tokens - MP.csv` at startup/sync time. The CSV must include `Location_ID`, `Nombre`, and `API_token`; tokens are cached in memory and are not stored in SQLite.
- The main brand account is hard-coded as location `GbKkBpCmKu2QmloKFHy3`; all other CSV locations are treated as branches.
## Generated Files
All dynamic outputs live under `generated/` and are sourced from `paths.py`. Never hardcode disk paths in new code — import from `paths` (or via `scripts/common.py`, which re-exports them). Layout: `generated/data/` (SQLite), `generated/reports/` (audit/dup/drift/coverage outputs), `generated/exports/` (downloads served by `/api/exports/`), `generated/logs/` (`errors.jsonl` + `script_runs/`), `generated/migrations/` (pre-destructive snapshots), `generated/browser/` (Playwright session, profile, screenshots), `generated/runtime/` (`server_info.json`, `last_mode`, in-flight bulk batches), `generated/_archive/` (legacy). The whole tree is gitignored and megaignored.
## GHL API Gotchas
- `ghl_client.py` is the executable source for GHL behavior: base URL `https://services.leadconnectorhq.com`, API version header `2021-07-28`, and bearer-token auth.
- Keep the per-token rate limiting in `GHLClient._wait_for_rate_limit`; global sync concurrency must use `SYNC_ENGINE_MAX_WORKERS` instead of hardcoded worker counts.
- Contacts paginate with `meta.nextPageUrl` and `startAfter`; do not replace this with offset pagination.
- Opportunities must be fetched with `POST /opportunities/search`; `GET /opportunities/` is documented here as returning empty/zero results.
- If GHL returns no pipelines but opportunities have pipeline IDs, sync creates synthetic pipelines from opportunity stages instead of failing.
## GHL Pagination & Full Dataset Retrieval
<!-- Obligatorio para todos los scripts de auditoría, búsqueda, sincronización o corrección: nunca asumir que una sola respuesta de GHL contiene todos los contactos u oportunidades. Todo script que necesite un escaneo completo debe paginar, acumular resultados, deduplicar por `id`, y reportar conteos finales. -->
- **Contactos:** usar la paginación cursor-based de GHL con `meta.nextPageUrl`, `startAfter` y, cuando aplique, `startAfterId`. No usar offset pagination. Continuar solicitando páginas hasta que no exista siguiente cursor/URL o hasta alcanzar un límite explícito del script (`--limit`, `--max-contacts`, etc.).
- **Oportunidades:** usar exclusivamente `POST /opportunities/search` por `locationId`. Si se requiere un dataset completo, paginar/iterar la búsqueda hasta agotar resultados o alcanzar un límite explícito. No usar `GET /opportunities/` para escaneos porque puede devolver vacío/cero resultados.
- **Acumulación segura:** cada página debe agregarse a una colección global, deduplicando por `id` para evitar repetidos entre páginas o reintentos.
- **Protección anti-loop:** todo paginador debe controlar cursores/URLs ya vistos, páginas vacías repetidas o límites máximos razonables para evitar ciclos infinitos.
- **Auditorías profundas:** antes de comparar, sincronizar o corregir datos, el script debe haber consolidado todas las páginas necesarias de contactos/oportunidades para no dejar datos fuera del análisis.
- **Resumen final:** los scripts que paginan deben reportar total de páginas consultadas, total recibido, total único procesado, duplicados descartados y si se cortó por límite.
## Dynamic Schema & Custom Fields Mapping
Para consultar y mapear campos personalizados cuyos IDs varían entre diferentes sucursales de GHL, se debe utilizar el siguiente flujo de API (ambos usando la cabecera `Version: 2021-04-15`):
<!-- Obligatorio para todos los scripts: antes de leer, comparar, sincronizar o actualizar campos personalizados de contactos u oportunidades en cualquier location, se debe consultar este flujo de endpoints y resolver los IDs dinámicos por nombre de campo. No se deben hardcodear IDs de custom fields entre sucursales. -->
1. **Obtener Catálogo de Objetos:**
- **Endpoint:** `GET https://services.leadconnectorhq.com/objects/?locationId={locationId}`
- **Detalle:** Requerido para inicializar y autorizar la sesión de consulta de esquemas de metadatos en GHL para esa ubicación.
2. **Obtener Esquema por Objeto (Schema by Key):**
- **Endpoint:** `GET https://services.leadconnectorhq.com/objects/{objectKey}?locationId={locationId}`
- **Parámetro `{objectKey}`:** Usar `contact` o `opportunity`.
- **Detalle:** Devuelve el listado completo de campos (estándar y personalizados). Permite mapear de forma dinámica campos por su nombre (ej. `"Canal de Origen"` o `"Fuente de Prospecto"`) a su correspondiente ID dinámico en esa sucursal para lectura o actualización segura.
## Business Logic & Field Correlations (Monte Providencia Rules)
Derivado de `DOCUMENTACIÓN de Monte Providencia.md`, estas reglas rigen el comportamiento de los scripts, la integridad de los datos y el mapeo en GHL:
### 1. Flujo de Sincronización y Prioridades
- **Sincronización de Contactos:** Bidireccional entre la cuenta de Marca principal (`GbKkBpCmKu2QmloKFHy3`) y las sucursales. Cualquier creación/modificación en un lado se sincroniza al otro.
- **Sincronización de Oportunidades:** Unidireccional de Sucursal a Marca (la sucursal tiene prioridad). Las modificaciones de oportunidades en la cuenta de marca no se sincronizan hacia las sucursales.
- **Multi-Oportunidades:** Un mismo contacto puede tener más de una oportunidad (múltiples intentos de empeño).
### 2. Campos Requeridos para Business Intelligence (BI)
Cada contacto u oportunidad debe poblar consistentemente:
- `Sucursal` (y campo `TIENDA`) alineados con el "verificador de sucursales".
- `Canal de Origen` (e.g., FORMULARIO, FACEBOOK, WHATSAPP, LLAMADA, INSTAGRAM, SUCURSAL).
- `Fuente de Prospecto` (e.g., CLIENTE CONOCIDO, SUCURSAL, PROSPECCIÓN, REFERIDO, ALIANZA, EVENTO ESPECIAL, LEAD DIGITAL).
### 3. Reglas de Validación de Origen/Fuente
- `LEAD DIGITAL` siempre proviene de un canal digital (Canal de Origen: Formulario, Facebook, etc.).
- Si el `Canal de Origen` es `SUCURSAL`, la `Fuente de Prospecto` **no puede ser** `LEAD DIGITAL`.
- El tag `"sucursal"` en contactos implica creación manual en sucursal, a menos que el campo `source` nativo de GHL indique lo contrario (un origen digital).
### 4. Correlación de Datos de Vehículo
- Para prospectos con fuente `LEAD DIGITAL`, el campo `Vehículo` de la oportunidad debe construirse concatenando los campos personalizados del contacto: `"Marca del Vehículo" + "Versión del Vehículo" + "Año del Vehículo"`.
- En oportunidades creadas manualmente, el campo de vehículo de la oportunidad puede diferir de los datos del contacto.
### 5. Pipelines y Oportunidades Huérfanas
- Toda oportunidad debe residir en un pipeline activo válido (comúnmente llamado `"Standar"`).
- Oportunidades huérfanas (sin pipeline válido) se deben reubicar según coincidencia de nombre de etapa (stage) o marcar para revisión.
## Scripts And Data Safety
- `script_runner.py` exposes only scripts listed in `SCRIPTS_METADATA`; add metadata there when adding a dashboard-runnable script.
- Most scripts read `mp_manager.sqlite` from the repo root and expect a prior dashboard sync. Some scripts are live GHL mutators (`fix_*`, `migrate_*`, `move_*`, `update_*`, `sync_contact_*`); prefer read-only audit/search scripts first unless the user explicitly asks to modify GHL data.
- Treat `Bucéfalo - Mesa de control - API Tokens - MP.csv` and any printed API tokens as secrets.
## Case log (registro de casos)
- `docs/casos/` is the chronological log of real operations/investigations on Bucéfalo, written **for agent recall** (dense, exact commands, literal ids/errors). It complements the `docs/PLAYBOOK_*` files (timeless theory) and the agent memory (atomic facts).
- **When to add a case:** after any Bucéfalo mutation (with `run_id`/snapshot) OR after closing a non-trivial investigation that reached a root cause (even with no mutation).
- **How:** copy `docs/casos/_PLANTILLA.md``docs/casos/YYYY-MM-DD-<slug>.md`, fill it, add the row to `docs/casos/INDEX.md`, and link the related memory with `[[slug]]`.
- **Before** investigating a new symptom, `grep` `docs/casos/` for the error/symptom — it may already be solved.
@@ -0,0 +1,52 @@
Nombre,Location_ID,API_token,WhatsApp_idWidget,Company Owner
AGENCIA BUCÉFALO,,pit-b487322b-e96a-4da5-afad-e73a158aeb96,,AGENCIA BUCÉFALO
Monte Providencia,GbKkBpCmKu2QmloKFHy3,pit-4e4266f8-97ac-4150-a971-cc9158809640,N/A,Monte Providencia
85932 - MP - La Viga,fKn9SaXZoKcjjLryg10v,pit-350d963b-952a-49ce-8063-eb28b9826ca1,N/A,Monte Providencia
85934 - MP - Atizapán,IE7ci6Ddfk1WvQabEa4q,pit-a263eb39-24b7-47d9-aadf-5ca9b997d15d,N/A,Monte Providencia
85935 - MP - Pilares,uZnMH5bO6MXTHcgHeyq9,pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5,N/A,Monte Providencia
85940 - MP - Isidro Fabela,clhDZ0hIllKfV0AcgW53,pit-01ca9688-9bec-4c24-afea-5df41ce88299,N/A,Monte Providencia
85941 - MP - Grand Plaza Toluca,pMPs9M4RaGJvWwfIFVIo,pit-070cce81-292c-4f63-8084-f804ec46e693,N/A,Monte Providencia
85945 - MP - Cuautla,arEgADUgzqjK3qH7LXSz,pit-19d774a4-5ad0-4421-be26-5033c4631a2a,N/A,Monte Providencia
85946 - MP - Jojutla,5qebe8IhFUjlcBvJcqCa,pit-53f52dc0-6089-4caa-aaee-ed17ae8b631c,N/A,Monte Providencia
85947 - MP - Chilpancingo,qe3s72MRDhbEWlaFZ2Ko,pit-4fcb095f-504b-4a38-a9ab-f7724ceee00e,N/A,Monte Providencia
85950 - MP - Temixco,yjqKxoO02rsdwdJZSPmD,pit-a369d5cd-f832-4a38-b2b9-d3a3316ee76b,N/A,Monte Providencia
85953 - MP - Texcoco,vuPH36qujg6dSf92P5p2,pit-0800a342-988c-4841-9295-bfced76e9209,N/A,Monte Providencia
85954 - MP - Izcalli,r0fiuXv6zQnFyXJW2SWU,pit-8e8596a8-5323-4f71-8e55-af2cf0f450d1,N/A,Monte Providencia
85960 - MP - Cd. Carmen,XkduzafvwsrWcEFg6Qlj,pit-d9d5e991-54d8-4c58-90ca-7cabf53247e5,N/A,Monte Providencia
85962 - MP - Tuxtla 3,y9nelIq8hkrfdCQKK72o,pit-07731a1a-a36a-4d4c-8076-8a27c5bde219,N/A,Monte Providencia
85964 - MP - Morelia 1,jE41bVhhnb5T505BFm4F,pit-0934169e-ce16-4a4e-a366-36e65edcee87,N/A,Monte Providencia
85966 - MP - Uruapan,FoQWuksh4wQjPbVVZ8ZQ,pit-66abaa78-3f09-4b87-ac81-e0e4369dfd2e,N/A,Monte Providencia
85967 - MP - Altamira,w1mYacmbTLjVwVDFF5Jx,pit-fcd0d3c6-e763-4d21-aac6-c0164ec4ef23,N/A,Monte Providencia
85969 - MP - Tampico,WCHyow6KpjLFYriQWdbJ,pit-06342f55-c0e0-4fa9-8dfe-af06e3d7786d,N/A,Monte Providencia
85973 - MP - Puebla,KEZ7dAhgwzK4uZfMvZuj,pit-7faad038-64a9-4ad7-8a40-9bece2ba4b66,N/A,Monte Providencia
85974 - MP - Eugenia,nF1uEaYB3mCK5em9bPn2,pit-dbdaabeb-b916-4ff7-baff-6c4fe31f656d,N/A,Monte Providencia
85975 - MP - Querétaro,blRZ21GlzgUCA7bl2uVw,pit-9ba01577-ccd7-42c6-a908-ba7594f0b97b,N/A,Monte Providencia
85976 - MP - Cancún,uJEn2iuUficuml9zxAnt,pit-7a23a1d8-01a3-486b-81e1-78e4b0f416c1,N/A,Monte Providencia
85977 - MP - Interlomas,2eJPAdEGjC7iPhDDAeoy,pit-a19a7d07-2658-4b5b-b7f6-48ce5b264c9b,N/A,Monte Providencia
Monte Providencia DEMO,Vf7qQl3L9vakJ8hDtQ8e,pit-f162f285-41fe-4b01-b681-aaaa52cd80ae,N/A,-
0001 - MP -Qro DEMO,Z64WQKORPVwXb5mn68Ef,pit-8215e76f-c2ea-43bf-95a1-34fb484c6c62,N/A,-
85931 - MP - Marina Nacional,HvDw9Eg3rjrwkbQJXqfi,pit-155a2c8f-069f-4879-9d42-804ea7e2c5dd,N/A,Monte Providencia
85930 - MP - TULYEHUALCO,rQYjjwsGnjEGagskOxix,pit-8001d42c-c771-47d3-8ec3-44521f4318d2,N/A,Monte Providencia
85956 - MP - PLAZA EL SALADO,WLPVTRxg7W074dfzBxZL,pit-ba851b3e-141b-4d90-b4a0-050e83d900a4,N/A,Monte Providencia
85933 - MP - CUAJIMALPA,VwDgbGbahFXPSyZmpCzt,pit-c1b5442f-81b5-46a1-818d-e7eae32a4066,N/A,Monte Providencia
85942 - MP - ZINACANTEPEC,MJU2fZ5VxQfHNgEfEQkg,pit-664d8847-9182-43d9-8c05-96a7d08e1733,N/A,Monte Providencia
85943 - MP - ATLACOMULCO,XzQ5Wi3RqHwc3AyE8QrI,pit-12986b2f-dda2-44f5-8fd5-1902f8bba377,N/A,Monte Providencia
85971 - MP - Satélite,R34lUVVpltnB8Z1RqnEB,pit-62df3e5c-55d0-4192-8f3a-06272e0eb1bd,N/A,Monte Providencia
85952 - MP - ECATEPEC,jB8BGwt9NoMAd3NGKjai,pit-298e19b3-94d4-493d-bc47-d5be3045f5a0,N/A,Monte Providencia
85968 - MP - HUAUCHINANGO,gtGA3sLqKBNSIAuf5hjx,pit-f510cc8c-b6e9-4b92-aa86-06df58099614,N/A,Monte Providencia
85955 - MP - IXMIQUILPAN,ZvVhdvqBU5K9YLEH0fy3,pit-88ff6484-12d7-4f39-b604-c09aa324afac,N/A,Monte Providencia
85948 - MP - MIACATLAN,RWqOypPx7S6t7MCleu2K,pit-a3ce8126-3409-427f-b703-c77741060b22,N/A,Monte Providencia
85959 - MP - MIAHUATLAN,YGOlLwAl2AeN9JtZ47vV,pit-119dc508-cfe7-4d69-8c89-a5f504fee1b3,N/A,Monte Providencia
85957 - MP - PINOTEPA,7H91g95hhLKwIUqSk0Rg,pit-f68877be-db7c-4414-afa1-dc434fbba0f0,N/A,Monte Providencia
85961 - MP - VILLAS DEL SOL,nRSeOhlhQ3vyirTKYhPi,pit-8e82f2f6-9e13-400d-af02-4ae61765aab3,N/A,Monte Providencia
85958 - MP - POCHUTLA,HvyNhH2IOe9ByeZrRo0N,pit-0fb64a32-5685-4f55-888b-dba78dedf9cd,N/A,Monte Providencia
85970 - MP - REYNOSA,eJq6hneY4n7m0WYGcN42,pit-ba5f6fad-83a1-47ff-b509-75229ff85e39,N/A,Monte Providencia
85963 - MP - TAPACHULA,ts3oTud0rw1Iat02zBAi,pit-a9fbab2c-9e83-4306-a0d8-c77c7c32c29f,N/A,Monte Providencia
85949 - MP - ZACATEPEC,U0S0QntXgSOz9Fx18Db4,pit-20e92122-65b6-40ec-9a24-f2e79884898b,N/A,Monte Providencia
85944 - MP - ZITACUARO,VYxTksrNuhmw9yL0Su2V,pit-7104e72f-7b7a-4a0c-be8b-f99c5e55b675,N/A,Monte Providencia
85937 - MP - METEPEC,NSDniGzjxotVDNa5YxqW,pit-aedff844-3aef-40a8-a201-6044a67b8710,N/A,Monte Providencia
85938 - MP - SENDERO,UsHXqoj2l6ND7Uc7sEo2,pit-b539f2ee-2b33-4e6b-8338-d4d7863758d3,N/A,Monte Providencia
85941 - MP - Grand Plaza,Xqpdy12avIk4NFsOhPBX,pit-aefd0fd7-2f77-4ef1-9b6e-716c07db924c,N/A,Monte Providencia
85939 - MP - Independencia,RLAs9sQwbW2DOwzrTMYI,pit-402e5801-127b-4ea7-b540-c068ca59a9bc,N/A,Monte Providencia
85935 - MP - Lerma,lWp7F6rsgTjy3voFBZ1m,pit-c1921eb8-52e6-43e8-818c-d518505406f7,N/A,Monte Providencia
85965 - MP - Morelia 3,rET7fvqI670aRPADfUwj,pit-1036c92c-9f79-4110-bd24-6b19cc600cf9,N/A,Monte Providencia
1 Nombre Location_ID API_token WhatsApp_idWidget Company Owner
2 AGENCIA BUCÉFALO pit-b487322b-e96a-4da5-afad-e73a158aeb96 AGENCIA BUCÉFALO
3 Monte Providencia GbKkBpCmKu2QmloKFHy3 pit-4e4266f8-97ac-4150-a971-cc9158809640 N/A Monte Providencia
4 85932 - MP - La Viga fKn9SaXZoKcjjLryg10v pit-350d963b-952a-49ce-8063-eb28b9826ca1 N/A Monte Providencia
5 85934 - MP - Atizapán IE7ci6Ddfk1WvQabEa4q pit-a263eb39-24b7-47d9-aadf-5ca9b997d15d N/A Monte Providencia
6 85935 - MP - Pilares uZnMH5bO6MXTHcgHeyq9 pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5 N/A Monte Providencia
7 85940 - MP - Isidro Fabela clhDZ0hIllKfV0AcgW53 pit-01ca9688-9bec-4c24-afea-5df41ce88299 N/A Monte Providencia
8 85941 - MP - Grand Plaza Toluca pMPs9M4RaGJvWwfIFVIo pit-070cce81-292c-4f63-8084-f804ec46e693 N/A Monte Providencia
9 85945 - MP - Cuautla arEgADUgzqjK3qH7LXSz pit-19d774a4-5ad0-4421-be26-5033c4631a2a N/A Monte Providencia
10 85946 - MP - Jojutla 5qebe8IhFUjlcBvJcqCa pit-53f52dc0-6089-4caa-aaee-ed17ae8b631c N/A Monte Providencia
11 85947 - MP - Chilpancingo qe3s72MRDhbEWlaFZ2Ko pit-4fcb095f-504b-4a38-a9ab-f7724ceee00e N/A Monte Providencia
12 85950 - MP - Temixco yjqKxoO02rsdwdJZSPmD pit-a369d5cd-f832-4a38-b2b9-d3a3316ee76b N/A Monte Providencia
13 85953 - MP - Texcoco vuPH36qujg6dSf92P5p2 pit-0800a342-988c-4841-9295-bfced76e9209 N/A Monte Providencia
14 85954 - MP - Izcalli r0fiuXv6zQnFyXJW2SWU pit-8e8596a8-5323-4f71-8e55-af2cf0f450d1 N/A Monte Providencia
15 85960 - MP - Cd. Carmen XkduzafvwsrWcEFg6Qlj pit-d9d5e991-54d8-4c58-90ca-7cabf53247e5 N/A Monte Providencia
16 85962 - MP - Tuxtla 3 y9nelIq8hkrfdCQKK72o pit-07731a1a-a36a-4d4c-8076-8a27c5bde219 N/A Monte Providencia
17 85964 - MP - Morelia 1 jE41bVhhnb5T505BFm4F pit-0934169e-ce16-4a4e-a366-36e65edcee87 N/A Monte Providencia
18 85966 - MP - Uruapan FoQWuksh4wQjPbVVZ8ZQ pit-66abaa78-3f09-4b87-ac81-e0e4369dfd2e N/A Monte Providencia
19 85967 - MP - Altamira w1mYacmbTLjVwVDFF5Jx pit-fcd0d3c6-e763-4d21-aac6-c0164ec4ef23 N/A Monte Providencia
20 85969 - MP - Tampico WCHyow6KpjLFYriQWdbJ pit-06342f55-c0e0-4fa9-8dfe-af06e3d7786d N/A Monte Providencia
21 85973 - MP - Puebla KEZ7dAhgwzK4uZfMvZuj pit-7faad038-64a9-4ad7-8a40-9bece2ba4b66 N/A Monte Providencia
22 85974 - MP - Eugenia nF1uEaYB3mCK5em9bPn2 pit-dbdaabeb-b916-4ff7-baff-6c4fe31f656d N/A Monte Providencia
23 85975 - MP - Querétaro blRZ21GlzgUCA7bl2uVw pit-9ba01577-ccd7-42c6-a908-ba7594f0b97b N/A Monte Providencia
24 85976 - MP - Cancún uJEn2iuUficuml9zxAnt pit-7a23a1d8-01a3-486b-81e1-78e4b0f416c1 N/A Monte Providencia
25 85977 - MP - Interlomas 2eJPAdEGjC7iPhDDAeoy pit-a19a7d07-2658-4b5b-b7f6-48ce5b264c9b N/A Monte Providencia
26 Monte Providencia DEMO Vf7qQl3L9vakJ8hDtQ8e pit-f162f285-41fe-4b01-b681-aaaa52cd80ae N/A -
27 0001 - MP -Qro DEMO Z64WQKORPVwXb5mn68Ef pit-8215e76f-c2ea-43bf-95a1-34fb484c6c62 N/A -
28 85931 - MP - Marina Nacional HvDw9Eg3rjrwkbQJXqfi pit-155a2c8f-069f-4879-9d42-804ea7e2c5dd N/A Monte Providencia
29 85930 - MP - TULYEHUALCO rQYjjwsGnjEGagskOxix pit-8001d42c-c771-47d3-8ec3-44521f4318d2 N/A Monte Providencia
30 85956 - MP - PLAZA EL SALADO WLPVTRxg7W074dfzBxZL pit-ba851b3e-141b-4d90-b4a0-050e83d900a4 N/A Monte Providencia
31 85933 - MP - CUAJIMALPA VwDgbGbahFXPSyZmpCzt pit-c1b5442f-81b5-46a1-818d-e7eae32a4066 N/A Monte Providencia
32 85942 - MP - ZINACANTEPEC MJU2fZ5VxQfHNgEfEQkg pit-664d8847-9182-43d9-8c05-96a7d08e1733 N/A Monte Providencia
33 85943 - MP - ATLACOMULCO XzQ5Wi3RqHwc3AyE8QrI pit-12986b2f-dda2-44f5-8fd5-1902f8bba377 N/A Monte Providencia
34 85971 - MP - Satélite R34lUVVpltnB8Z1RqnEB pit-62df3e5c-55d0-4192-8f3a-06272e0eb1bd N/A Monte Providencia
35 85952 - MP - ECATEPEC jB8BGwt9NoMAd3NGKjai pit-298e19b3-94d4-493d-bc47-d5be3045f5a0 N/A Monte Providencia
36 85968 - MP - HUAUCHINANGO gtGA3sLqKBNSIAuf5hjx pit-f510cc8c-b6e9-4b92-aa86-06df58099614 N/A Monte Providencia
37 85955 - MP - IXMIQUILPAN ZvVhdvqBU5K9YLEH0fy3 pit-88ff6484-12d7-4f39-b604-c09aa324afac N/A Monte Providencia
38 85948 - MP - MIACATLAN RWqOypPx7S6t7MCleu2K pit-a3ce8126-3409-427f-b703-c77741060b22 N/A Monte Providencia
39 85959 - MP - MIAHUATLAN YGOlLwAl2AeN9JtZ47vV pit-119dc508-cfe7-4d69-8c89-a5f504fee1b3 N/A Monte Providencia
40 85957 - MP - PINOTEPA 7H91g95hhLKwIUqSk0Rg pit-f68877be-db7c-4414-afa1-dc434fbba0f0 N/A Monte Providencia
41 85961 - MP - VILLAS DEL SOL nRSeOhlhQ3vyirTKYhPi pit-8e82f2f6-9e13-400d-af02-4ae61765aab3 N/A Monte Providencia
42 85958 - MP - POCHUTLA HvyNhH2IOe9ByeZrRo0N pit-0fb64a32-5685-4f55-888b-dba78dedf9cd N/A Monte Providencia
43 85970 - MP - REYNOSA eJq6hneY4n7m0WYGcN42 pit-ba5f6fad-83a1-47ff-b509-75229ff85e39 N/A Monte Providencia
44 85963 - MP - TAPACHULA ts3oTud0rw1Iat02zBAi pit-a9fbab2c-9e83-4306-a0d8-c77c7c32c29f N/A Monte Providencia
45 85949 - MP - ZACATEPEC U0S0QntXgSOz9Fx18Db4 pit-20e92122-65b6-40ec-9a24-f2e79884898b N/A Monte Providencia
46 85944 - MP - ZITACUARO VYxTksrNuhmw9yL0Su2V pit-7104e72f-7b7a-4a0c-be8b-f99c5e55b675 N/A Monte Providencia
47 85937 - MP - METEPEC NSDniGzjxotVDNa5YxqW pit-aedff844-3aef-40a8-a201-6044a67b8710 N/A Monte Providencia
48 85938 - MP - SENDERO UsHXqoj2l6ND7Uc7sEo2 pit-b539f2ee-2b33-4e6b-8338-d4d7863758d3 N/A Monte Providencia
49 85941 - MP - Grand Plaza Xqpdy12avIk4NFsOhPBX pit-aefd0fd7-2f77-4ef1-9b6e-716c07db924c N/A Monte Providencia
50 85939 - MP - Independencia RLAs9sQwbW2DOwzrTMYI pit-402e5801-127b-4ea7-b540-c068ca59a9bc N/A Monte Providencia
51 85935 - MP - Lerma lWp7F6rsgTjy3voFBZ1m pit-c1921eb8-52e6-43e8-818c-d518505406f7 N/A Monte Providencia
52 85965 - MP - Morelia 3 rET7fvqI670aRPADfUwj pit-1036c92c-9f79-4110-bd24-6b19cc600cf9 N/A Monte Providencia
+109
View File
@@ -0,0 +1,109 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> Este repo ya tiene un [AGENTS.md](AGENTS.md) con documentación operativa detallada (comandos, gotchas de GHL, paginación, reglas de negocio Monte Providencia). **Léelo siempre primero.** Este archivo cubre el panorama arquitectónico que no es obvio leyendo un único módulo.
## Comandos esenciales
- `python -m pip install -r requirements.txt` — instalar dependencias.
- `python main.py` — levanta FastAPI/Uvicorn en `http://127.0.0.1:8000` con reload.
- `start.bat` / `stop.bat` / `restart.bat` (Windows) — lanzan/matan/reinician la app y liberan el puerto 8000. `restart.bat` detecta automáticamente si estaba en modo normal o perfil persistente (lee `generated/runtime/last_mode`), mata Chromium zombies y limpia batch files huérfanos en `generated/runtime/batch/`.
- `python -m py_compile main.py db.py ghl_client.py sync_engine.py script_runner.py paths.py` — único "lint" disponible (no hay test/lint/format configurado).
- Scripts de utilidad se corren directo: `python scripts\<nombre>.py [args]`. La mayoría leen `generated/data/mp_manager.sqlite` (vía `paths.DB_PATH`) y asumen una sync previa.
- Variables de entorno:
- `SYNC_ENGINE_MAX_WORKERS` (default 1000, sin tope) — paralelismo de "Sincronizar Todo". La concurrencia efectiva es `min(total_accounts, env_var)`, así que el default sincroniza todas las cuentas a la vez. El rate limit de GHL es por location, así que paralelizar más cuentas no provoca 429; bajar este valor solo tiene sentido para acotar uso de CPU/red local.
- `SCRIPT_RUNNER_MAX_WORKERS` (default 4, máx 20) — paralelismo del runner del dashboard. Subir esto aumenta riesgo de `429` porque los subprocesos no comparten rate limit.
## Arquitectura — el panorama
**Patrón general:** el repo es un panel de control FastAPI sobre SQLite que cachea datos de varias cuentas (locations) de Go High Level y orquesta scripts Python que pueden auditar o mutar datos directamente en GHL.
### Flujo de datos
```
CSV de tokens ──► sync_engine ──► ghl_client (API GHL) ──► db.py (SQLite)
┌────────────────────────┘
main.py (FastAPI) ◄── templates/index.html + static/js
script_runner ──► subprocess scripts/*.py ──► (audita en script_audit, muta en GHL)
```
### Módulos núcleo (raíz del repo)
| Módulo | Rol |
|---|---|
| `main.py` | Punto de entrada FastAPI. Define endpoints, middleware de request_id, manejadores de excepción que escriben a `error_log`. En startup carga `TOKENS_CACHE` desde el CSV — los tokens nunca tocan SQLite. |
| `paths.py` | Fuente única de verdad para rutas en disco. Todo lo generado por la app/scripts (DB, reports, exports, logs, screenshots, sesiones Playwright, snapshots de migración, estado de runtime) vive bajo `generated/`. Cualquier módulo nuevo debe importar sus paths desde aquí — nunca hardcodear. |
| `db.py` | Capa SQLite. Define el esquema (`accounts`, `contacts`, `pipelines`, `opportunities`, `workflows`, `sync_log`, `error_log`) y operaciones de lectura/escritura. Cada `save_*` por location se hace en una transacción que reemplaza los registros previos de esa location. |
| `ghl_client.py` | Cliente HTTP de GHL. Implementa rate limiting (110ms entre peticiones por token), reintentos con backoff exponencial para 5xx y lineal para 429, sesión TCP persistente por hilo. Es la fuente de verdad del comportamiento GHL. |
| `sync_engine.py` | Orquesta `sync_account` (pipelines → opportunities → contacts → guardado transaccional) y `sync_all_accounts` (ThreadPoolExecutor con `SYNC_ENGINE_MAX_WORKERS`). Lee el CSV en cada arranque y sincronización. |
| `script_runner.py` | Lanza scripts como subprocesos, encola sus logs para SSE, maneja ejecución paralela vs secuencial, asocia cada run a un `run_id` para auditoría. Sólo expone los scripts listados en `SCRIPTS_METADATA`. |
| `script_audit.py` | Sistema de auditoría/rollback paralelo: `script_runs`, `script_change_log`, `script_run_control`. Los scripts mutadores deben registrar cada cambio aquí (estado `planned``applied` → opcionalmente `rolled_back`) para que el dashboard pueda revertirlos por `run_id`. |
| `error_logging.py` | Centraliza logging técnico. Cada error retorna un `error_id` que se devuelve al cliente y se persiste en `error_log`. |
### Carpeta `generated/`
Todos los archivos que produce la app o los scripts viven bajo `generated/`. La raíz del repo solo contiene código fuente, docs, configs estáticos (CSVs de tokens y verificador) y `script_metadata_overrides.json` (config viva del dashboard).
```
generated/
├── data/ mp_manager.sqlite (+ -shm, -wal)
├── reports/ outputs de scripts de auditoría (audit_custom_fields/, duplicados/, drift/, coverage/)
├── exports/ descargas del dashboard (servido vía /api/exports/{filename})
├── logs/ errors.jsonl + script_runs/ para runs futuros
├── migrations/ snapshots pre-destructive de scripts mutadores
├── browser/ session.json (Playwright shared), profile/, screenshots/
├── runtime/ server_info.json, last_mode, batch/ (bulk batches en vuelo)
└── _archive/ basura legacy fechada
```
Está ignorada en `.gitignore` y `.megaignore`. Para añadir un nuevo destino, agregar la constante en `paths.py` y registrar el directorio en `ensure_dirs()`.
### Cuenta principal vs sucursales
El `location_id` `GbKkBpCmKu2QmloKFHy3` es la **cuenta de Marca principal** (Monte Providencia). Está hardcodeado en `sync_engine.parse_accounts_csv` y `scripts/common.py` (`BRAND_LOCATION_ID`). Todas las demás filas del CSV son sucursales. La sincronización de contactos es bidireccional Marca↔Sucursal; la de oportunidades es unidireccional Sucursal→Marca (la sucursal tiene prioridad). Ver AGENTS.md para las reglas completas.
### Custom fields dinámicos
Los IDs de custom fields varían por sucursal. **Nunca los hardcodees.** Resuélvelos por nombre via los endpoints `GET /objects/?locationId=…` y `GET /objects/{contact|opportunity}?locationId=…` (header `Version: 2021-04-15`). `scripts/common.py` expone `SchemaResolver` y `FIELD_ALIASES` para esto — úsalo en scripts nuevos en vez de reinventar.
### Convenciones de scripts
Los scripts viven en `scripts/` y se categorizan por nombre:
- **Read-only**: `audit_*`, `mp_*_search`, `analyze_*`, `find_*`, `check_*`, `health_check_*`, `*_readonly`, `daily_summary_*`.
- **Mutadores** (escriben en GHL): `fix_*`, `migrate_*`, `move_*`, `update_*`, `sync_contact_*`, `cleanup_*`, `reconcile_*`, `fill_*`. Estos deben soportar `--dry-run` y registrar cambios en `script_audit` con su `--run-id`.
- Todos los scripts comparten utilidades en `scripts/common.py` (carga de cuentas, resolver de schemas, normalización de nombres).
- Para que un script aparezca en el dashboard hay que agregarlo a `SCRIPTS_METADATA` en `script_runner.py` (título, descripción, categoría, opciones, `mutator: True` si aplica).
### Frontend
`templates/index.html` + `static/js/app.js` + `static/css/style.css` son el SPA. Consume los endpoints `/api/*` y streamea logs de scripts via SSE en `/api/scripts/stream/{task_id}`.
### Automatización con navegador
`scripts/ghl_browser_*.py` usan Playwright contra la UI web de GHL para cosas que la API no soporta (renombrar/eliminar workflows, p.ej.). Soportan dos modos de sesión: shared `storage_state` en `generated/browser/session.json` (default) o perfil Chrome persistente vía `GHL_BROWSER_PROFILE_DIR` (más estable, default `generated/browser/profile`, arrancar con `start_persistent_profile.bat`). El auto-login con 2FA por correo (IMAP) se configura en `.env` (ver `.env.example`).
Antes de tocar estos scripts:
- [docs/PLAYWRIGHT_SESSION.md](docs/PLAYWRIGHT_SESSION.md) — manejo de sesión, modos, síntomas comunes, cómo reportar errores útilmente.
- [docs/PLAYWRIGHT_PATTERNS.md](docs/PLAYWRIGHT_PATTERNS.md) — **patrones probados** (SPA detection por URL + DOM, OTP en inputs maxlength=1, selectores defensivos, screenshots de debug, validación contra API tras mutaciones, `SessionExpiredError` para abortar early en bulks). Incluye plantilla para scripts nuevos y checklist de revisión.
## Capa agentica MCP
El proyecto expone un servidor MCP (stdio) en `mcp_server/` que Claude Code consume automáticamente vía `.mcp.json` en la raíz. Da acceso tipado a cuentas, contactos, opps, syncs y al runner genérico `run_script` que cubre los ~60 scripts no expuestos como tool dedicada. Defaults seguros: toda tool mutadora arranca con `apply=False` y requiere `confirm_token="I-HAVE-USER-CONFIRMATION"` para aplicar — forzando confirmación humana explícita. Los runs con `apply=True` quedan en `script_audit` y son reversibles desde el dashboard.
- **Guía user-facing** (cómo aprovecharlo desde Claude Code, recetas y prompts): [docs/GUIA_AGENTICA.md](docs/GUIA_AGENTICA.md).
- **Catálogo técnico** (contrato, tools, troubleshooting): [docs/AGENT_TOOLS.md](docs/AGENT_TOOLS.md).
- **Manifest autogenerado**: `generated/agent/tools_manifest.json`. **Auditoría de salud**: `python scripts/audit_agent_readiness.py`.
## Reglas críticas que no son obvias
1. **El CSV `Bucéfalo - Mesa de control - API Tokens - MP.csv` y los tokens son secretos.** No los imprimas en logs ni los persistas en SQLite.
2. **Paginación GHL obligatoria**: contactos con `meta.nextPageUrl`/`startAfter`, oportunidades con `POST /opportunities/search`. Nunca uses offset pagination ni `GET /opportunities/`. Detalle completo en AGENTS.md.
3. **Marca:** Bucéfalo es nuestro CRM. Aunque GHL aparece en el código por necesidad técnica, no lo menciones como producto en interfaces de usuario o documentación destinada a clientes.
4. **Pipelines sintéticos**: si GHL devuelve `[]` de pipelines pero hay oportunidades con `pipelineId`, `sync_engine` los reconstruye desde las etapas — no es bug, es fallback.
5. **Antes de modificar GHL**: prefiere correr el script en modo `--dry-run` y revisar el resumen de auditoría antes de aplicar.
6. **Registro de casos** (`docs/casos/`): bitácora de operaciones/investigaciones reales, optimizada para recall del agente. **Antes** de investigar un síntoma nuevo, `grep` ahí por el error/síntoma (quizá ya está resuelto). **Después** de mutar Bucéfalo o cerrar una investigación con causa raíz, registra el caso (copia `docs/casos/_PLANTILLA.md`, agrega fila a `docs/casos/INDEX.md`). Es complemento de los `PLAYBOOK_*` (teoría) y la memoria (hechos atómicos).
+317
View File
@@ -0,0 +1,317 @@
# DOCUMENTACIÓN de Monte Providencia
: URIEL JARETH ALVARADO ORTIZ
Area: CRMS
Cliente: Monte Providencia
Equipo: URIEL JARETH ALVARADO ORTIZ
Prioridad: Muy Alta
Responsable: URIEL JARETH ALVARADO ORTIZ
Revisión: No
Status: Mensual
Última edición: 20 de mayo de 2026 22:30
Última edición por: URIEL JARETH ALVARADO ORTIZ
## Descripción breve de Monte Providencia
Monte Providencia es una empresa enfocada en empeño de autos con N sucursales en México. Algunas sucursales son FULL AUTOS (enfocados 100% en empeño de autos), algunas otras sucursales trabajan parcialmente con el empeño de autos.
## Contexto en Bucéfalo CRM (Go High Level)
La cuenta de marca “Monte Providencia” (locationid=GbKkBpCmKu2QmloKFHy3) manda los datos de contactos creados con sus sucursales con nomenclatura de nombre de cuenta #### - MP - TIENDA, donde #### es el codigo de tienda y TIENDA=Nombre de tienda. Los contactos en la cuenta principal se crean por formulario, redes sociales o integraciones por API por desarrollos especificos. Cada sucursal es un location en GHL.
Los contactos se sincronizan con los contactos de sucursales. Si un contacto se crea/modifica en sucursal se crea/modifica en cuenta de marca. Si un contacto se crea/modifica en marca se crea/modifica en sucursal. En el caso de oportunidades es la sucursal que tiene la prioridad, si una oportunidad se actualiza en sucursal se actualiza en cuenta de marca pero no a la inversa.
Un contacto puede tener más de una oportunida (posibilidad de empeño) por contacto.
## Datos relevantes de contacto y oportunidad
Los datos de contacto y sucursal son los minimos aceptados.
### Datos de contacto
- Nombre
- Apellido
- Teléfono
- Correo Electrónico
### Datos de vehículo
- SUCURSAL
- MARCA DE VEHÍCULO
- VERSIÓN DEL VEHÍCULO
- AÑO DE VEHÍCULO
- MODALIDAD DE EMPEÑO
- Sin Dejarlo (GPS)
- Tradicional (Resguardo)
### Acepetación de terminos (obigatorio para formulario web)
- **Acepta los términos para tu cotización ***
- Política de Privacidad para comunicación de marketing
### Pipeline y etapas
- Etapas
- PROSPECTO NUEVO
- Intento de contacto
- Contactado
- Cliente Interesado
- Cumple requisitos
- Solicitud Iniciada
- Préstamo aprobado
- En Pausa
### Campos personalizados relevantes (contacto y oportunidad)
- CANAL DE ORIGEN: Formulario, Llamada, WhatsApp, Facebook, Instagram, Sucursal.
- **Fuente de Prospecto: CLIENTE CONOCIDO, SUCURSAL, PROSPECCIÓN, REFERIDO, ALIANZA, EVENTO ESPECIAL, LEAD DIGITAL**
IMPORTANTE: LEAD DIGITAL siempre proviene de algun medio digital. El canal de origen de sucursal podría tener cualquier fuente de prospecto menos LEAD DIGITAL.
## Colección de campos personalizados (RELEVANTE PARA API)
### Paginación técnica obligatoria para escaneos completos en GHL
Cuando un script necesite auditar, buscar, sincronizar o corregir contactos u oportunidades en GHL, no debe asumir que una sola respuesta de API contiene todos los registros. Para evitar que queden datos fuera del escaneo, cada script debe implementar paginación completa, acumulación de resultados y deduplicación por `id`.
#### Contactos
Los contactos deben obtenerse usando la paginación cursor-based de GHL.
- Endpoint habitual: `GET https://services.leadconnectorhq.com/contacts/`
- Parámetros mínimos: `locationId={locationId}` y `limit`.
- Cursor/paginación: usar `meta.nextPageUrl`, `startAfter` y, cuando aplique, `startAfterId`.
- Regla: no usar paginación por offset para contactos.
- El script debe seguir solicitando páginas hasta que no exista `meta.nextPageUrl` ni cursor siguiente, o hasta alcanzar un límite explícito definido por el usuario (`--limit`, `--max-contacts`, etc.).
Flujo recomendado:
1. Inicializar `contacts = []`, `seen_ids = set()` y cursores vacíos.
2. Solicitar la primera página con `locationId` y `limit`.
3. Agregar cada contacto solo si su `id` no existe en `seen_ids`.
4. Leer `meta.nextPageUrl` y/o cursores `startAfter` / `startAfterId` para la siguiente página.
5. Detenerse cuando no haya siguiente cursor/URL, cuando una página venga vacía sin nuevo cursor, o cuando se alcance un límite explícito.
6. Reportar páginas consultadas, contactos recibidos, contactos únicos y duplicados descartados.
#### Oportunidades
Las oportunidades deben obtenerse con búsqueda por API, no con el endpoint simple de listado.
- Endpoint obligatorio: `POST https://services.leadconnectorhq.com/opportunities/search`
- Parámetro/filtro requerido: `locationId={locationId}`.
- Regla: no usar `GET /opportunities/` para escaneos completos porque puede devolver vacío o cero resultados aunque existan oportunidades.
- Si el resultado de búsqueda devuelve paginación/cursor o parámetros de página soportados por la API, el script debe iterar hasta agotar resultados o llegar a un límite explícito.
Flujo recomendado:
1. Inicializar `opportunities = []`, `seen_ids = set()` y estado de paginación vacío.
2. Ejecutar `POST /opportunities/search` con `locationId` y el tamaño de página permitido.
3. Agregar cada oportunidad solo si su `id` no existe en `seen_ids`.
4. Avanzar a la siguiente página usando el cursor/parámetro devuelto por la API, si existe.
5. Detenerse cuando no haya más resultados, cuando no haya cursor siguiente, cuando se repita un cursor ya visto o cuando se alcance un límite explícito.
6. Reportar páginas consultadas, oportunidades recibidas, oportunidades únicas y duplicados descartados.
#### Reglas de seguridad para cualquier paginador
- Deduplicar siempre por `id` antes de auditar, comparar, sincronizar o actualizar.
- Mantener un registro de cursores o URLs ya vistos para evitar loops infinitos.
- Implementar límites explícitos cuando el script sea de investigación (`--limit`, `--max-contacts`, `--max-opportunities`) y dejar claro en el resumen si el corte fue por límite.
- Respetar el rate limit por token antes de cada request.
- En ejecuciones paralelas, no aumentar workers sin considerar el rate limit de GHL.
- Si una página falla, registrar el error con contexto (`locationId`, endpoint, página/cursor) y reportarlo en el resumen final.
- En auditorías profundas, primero consolidar el dataset completo requerido y después ejecutar comparaciones, conteos o correcciones.
#### Resumen final esperado en scripts con paginación
Todo script que haga escaneos paginados debe reportar al final:
- Locations procesadas.
- Páginas consultadas.
- Registros recibidos desde GHL.
- Registros únicos procesados.
- Duplicados descartados.
- Registros omitidos por límite, si aplica.
- Errores por location o endpoint, si existieron.
### Mapeo técnico obligatorio de campos personalizados en GHL
Los IDs de campos personalizados de GHL pueden cambiar entre la cuenta de marca y cada sucursal. Por esta razón, todos los scripts que lean, comparen, sincronicen o actualicen campos personalizados de contactos u oportunidades deben resolver los IDs dinámicamente por `locationId` y por nombre de campo antes de operar datos.
Flujo obligatorio por cada location:
1. Consultar catálogo de objetos de la ubicación:
- Documentación: `https://marketplace.gohighlevel.com/docs/ghl/objects/get-object-by-location-id`
- Endpoint API: `GET https://services.leadconnectorhq.com/objects/?locationId={locationId}`
- Header requerido: `Version: 2021-04-15`
- Objetivo: inicializar/autorizar la consulta de metadatos de objetos para esa ubicación.
2. Consultar esquema del objeto requerido:
- Documentación: `https://marketplace.gohighlevel.com/docs/ghl/objects/get-object-schema-by-key`
- Endpoint API: `GET https://services.leadconnectorhq.com/objects/{objectKey}?locationId={locationId}`
- Header requerido: `Version: 2021-04-15`
- `objectKey`: usar `contact` para campos de contacto y `opportunity` para campos de oportunidad.
- Objetivo: obtener campos estándar y personalizados para mapear nombres funcionales como `Canal de Origen`, `Fuente de Prospecto`, `Sucursal`, `TIENDA`, `Vehículo`, `Marca del Vehículo`, `Versión del Vehículo` y `Año del Vehículo` a su ID real en esa sucursal.
Reglas técnicas:
- Ningún script debe hardcodear IDs de campos personalizados para contactos u oportunidades entre sucursales.
- El mapeo debe hacerse por nombre de campo y por `locationId`; un ID válido en una sucursal no debe asumirse válido en otra.
- Si un campo requerido no existe en el esquema de una ubicación, el script debe reportarlo claramente y evitar actualizaciones parciales inseguras.
- Esta regla aplica tanto para scripts de auditoría/lectura como para scripts mutadores (`fix_*`, `migrate_*`, `move_*`, `update_*`, `sync_contact_*`).
### Contactos
- Sucursal: En formulario normalmente se llena en automatico, cuando se crea en sucursal se hace una relación con el verificador de sucursal con el campo de TIENDA o con el nombre de la cuenta en GHL de ahi se hace la relación ya que el verificador de sucural ya tiene mapeado los campos, si no hay campo se deja vacio.
- Versión del vehículo
- Marca del Vehículo
- Año del Vehículo
- ¿Qué modalidad prefieres?
- Acepta los terminos para tu cotización
- ¿Cuándo necesitas el dinero?
- Check Comunicaciones de Marketing
- Canal de Origen
- FORMULARIO
- FACEBOOK
- WHATSAPP
- LLAMADA
- INSTAGRAM
- SUCURSAL
- Fuente de Prospecto
- CLIENTE CONOCIDO
- SUCURSAL
- PROSPECCIÓN
- REFERIDO
- ALIANZA
- EVENTO ESPECIAL
- LEAD DIGITAL
- REDES SOCIALES
- GALLARDETES
- TIENDA
- ALTAMIRA
- ATIZAPAN
- ATLACOMULCO
- CANCUN
- CD CARMEN
- CHILPANCINGO
- CUAJIMALPA
- CUAUTLA
- IZCALLI
- ECATEPEC
- EUGENIA
- GRAND PLAZA
- HUAUCHINANGO
- INDEPENDENCIA
- INTERLOMAS
- ISIDRO FABELA
- IXMIQUILPAN
- ZACATEPEC
- JOJUTLA
- LA VIGA
- MARINA NACIONAL
- METEPEC
- MIACATLAN
- MIAHUATLAN
- MORELIA 1
- MORELIA 3
- PILARES
- PINOTEPA
- PLAZA EL SALADO
- POCHUTLA
- PUEBLA
- QUERETARO
- REYNOSA
- SATELITE
- ZINACANTEPEC
- ZITACUARO
- TAMPICO
- TAPACHULA
- TEMIXCO
- TEXCOCO
- TULYEHUALCO
- TUXTLA 3
- URUAPAN
- VILLAS DEL SOL
- SENDERO
### Oportunidades
- CANAL DE ORIGEN
- Sucursal
- Persona que atendió al prospecto: Campo libre donde el asesor escribe su nombre ya que un solo usuario maneja el CRM pero varias personas usan el mismo usuario.
- Visita a sucursal: check para indicar si el prospecto visitó la sucursal.
- **Fecha de última visita a sucursal: fecha de calendario que indica la ultima visita del usuario.**
- **Fuente de Prospecto**
- CLIENTE CONOCIDO
- SUCURSAL
- PROSPECCIÓN
- REFERIDO
- ALIANZA
- EVENTO ESPECIAL
- LEAD DIGITAL
- REDES SOCIALES
- GALLARDETES
- Vehículo
- **Modalidad de Empeño**
- TIENDA
- ALTAMIRA
- ATIZAPAN
- ATLACOMULCO
- CANCUN
- CD CARMEN
- CHILPANCINGO
- CUAJIMALPA
- CUAUTLA
- IZCALLI
- ECATEPEC
- EUGENIA
- GRAND PLAZA
- HUAUCHINANGO
- INDEPENDENCIA
- INTERLOMAS
- ISIDRO FABELA
- IXMIQUILPAN
- ZACATEPEC
- JOJUTLA
- LA VIGA
- MARINA NACIONAL
- METEPEC
- MIACATLAN
- MIAHUATLAN
- MORELIA 1
- MORELIA 3
- PILARES
- PINOTEPA
- PLAZA EL SALADO
- POCHUTLA
- PUEBLA
- QUERETARO
- REYNOSA
- SATELITE
- ZINACANTEPEC
- ZITACUARO
- TAMPICO
- TAPACHULA
- TEMIXCO
- TEXCOCO
- TULYEHUALCO
- TUXTLA 3
- URUAPAN
- VILLAS DEL SOL
- SENDERO
## CORRELACIÓN ENTRE CONTACTOS Y OPORTUNIDADES (campos personalizados)
Los campos de sucursal (y tienda proximamente) siempre tienen que estar sincronizados y ser iguales con valores validos del “verificador de sucursales”.
Si un contacto tiene la etiqueta “sucursal” siempre será creado de manera manual siempre y cuando el contacto no aclare en el campo “source” (de API) algo que sea lo contrario (facebook, instagram, etc).
Los datos de vehiculo en contacto y oportunidad no tienen que ser siempre los mismos ya que en la oportunidad es la suma de los campos de contacto “Marca del vehiculo”, “Versión del Vehiculo” “año del vehiculó” pero solo cuando es lead digital. Si se crea una oportunidad de manera manual ya puede ser diferente el campo personalizado de vehículo en oportunidad.
## DATOS QUE SIEMPRE HAY QUE TENER PARA BUSINESS INTELLIGENCE
- Sucursal
- Canal de origen
- Fuente del Prospecto
### Consideración extra de oportunidades.
Todas las oportunidades deben de estar en un pipeline valido. Generalmente llamado “Standar”, en caso de ser necesario reubicar se debe de hacer con base al nombre de la etapa/stage. En caso de que sea oportunidad huerfana (no ubicado en pipeline valido) se debe de reubicar o marcar para su revisión.
@@ -0,0 +1,798 @@
# GoHighLevel Users API — Documentación Completa por Endpoint
> **Fuentes:** [GitHub OpenAPI spec](https://github.com/GoHighLevel/highlevel-api-docs/blob/main/apps/users.json) · [Marketplace Docs](https://marketplace.gohighlevel.com/docs/ghl/users/) · Perplexity Search
> **Base URL:** `https://services.leadconnectorhq.com`
> **Auth:** `Authorization: Bearer {TOKEN}` | `Version: 2021-07-28` | `User-Agent: Mozilla/5.0`
---
## 1. POST /users/ — Create User
Crea un nuevo usuario en la agencia y lo asigna a una o más locations.
### cURL
```bash
curl -X POST https://services.leadconnectorhq.com/users/ \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Version: 2021-07-28" \
-H "User-Agent: Mozilla/5.0" \
-d '{
"companyId": "ve9EPM428h8vShlRW1KT",
"firstName": "John",
"lastName": "Deo",
"email": "john@deo.com",
"password": "*******",
"phone": "+18832327657",
"type": "account",
"role": "admin",
"locationIds": ["C2QujeCh8ZnC7al2InWR"],
"permissions": {
"contactsEnabled": true,
"opportunitiesEnabled": true,
"conversationsEnabled": true
},
"scopes": ["contacts.write", "opportunities.write"]
}'
```
### Python
```python
import http.client
import json
def create_user(token, company_id, first_name, last_name, email, password,
location_ids, role="user", type_="account", phone=None,
permissions=None, scopes=None, language="es"):
"""
POST /users/ — Create a user in GHL.
Args:
token: API token (pit-xxx or OAuth)
company_id: Agency ID
location_ids: List of location IDs to assign user to
permissions: Dict of 39 boolean flags (see §6 of research doc)
scopes: List of scope strings (see §7 of research doc)
Returns:
dict with user data (id, name, email, phone) or error
"""
payload = {
"companyId": company_id,
"firstName": first_name,
"lastName": last_name,
"email": email,
"password": password,
"type": type_,
"role": role,
"locationIds": location_ids,
"platformLanguage": language
}
if phone:
payload["phone"] = phone
if permissions:
payload["permissions"] = permissions
if scopes:
payload["scopes"] = scopes
body = json.dumps(payload).encode()
conn = http.client.HTTPSConnection("services.leadconnectorhq.com", timeout=30)
conn.request("POST", "/users/", body, {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Version": "2021-07-28",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"
})
res = conn.getresponse()
data = json.loads(res.read().decode())
conn.close()
if res.status == 201:
return data.get("user", data) # Response wraps in 'user' sometimes
else:
return {"error": res.status, "message": data.get("message", str(data))}
# ── Usage ──
user = create_user(
token="pit-xxxxxxxx",
company_id="ve9EPM428h8vShlRW1KT",
first_name="Martin",
last_name="Macedo",
email="abejorrito79@gmail.com",
password="********",
phone="+527775610738",
location_ids=["U0S0QntXgSOz9Fx18Db4"],
role="user",
permissions={
"contactsEnabled": True,
"opportunitiesEnabled": True,
"conversationsEnabled": True,
"assignedDataOnly": True
},
scopes=["contacts.write", "opportunities.write", "conversations.write"]
)
print(user) # {"id": "0IHuJvc2ofPAAA8GzTRi", "name": "Martin Macedo", ...}
```
### Request Body Schema
| Campo | Tipo | Requerido | Descripción |
|-------|------|-----------|-------------|
| `companyId` | string | ✅ | ID de la agencia |
| `firstName` | string | ✅ | Nombre |
| `lastName` | string | ✅ | Apellido |
| `email` | string | ✅ | Email (debe ser único) |
| `password` | string | ✅ | Contraseña |
| `type` | string | ✅ | `account` o `agency` |
| `role` | string | ✅ | `admin`, `user`, `agency_admin` |
| `locationIds` | string[] | ✅ | Array de Location IDs |
| `phone` | string | | Teléfono en E.164 |
| `permissions` | object | | 39 flags booleanos |
| `scopes` | string[] | | 189 scopes OAuth |
| `scopesAssignedToOnly` | string[] | | Scopes asignados |
| `profilePhoto` | string | | URL de foto |
| `twilioPhone` | object | | `{"locId": "+123"}` — semántica replace |
| `platformLanguage` | string | | `es`, `en_US`, `fr_CA`, etc. |
### Response `201 Created`
```json
{
"id": "0IHuJvc2ofPAAA8GzTRi",
"name": "John Deo",
"firstName": "John",
"lastName": "Deo",
"email": "john@deo.com",
"phone": "+1 808-868-8888",
"extension": "",
"permissions": {...},
"scopes": "...",
"roles": {...},
"deleted": false,
"lcPhone": {"locationId": "+1234556677"},
"platformLanguage": "en_US"
}
```
---
## 2. GET /users/{userId} — Get User
Obtiene todos los datos de un usuario por su ID.
### cURL
```bash
curl -X GET https://services.leadconnectorhq.com/users/{userId} \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Version: 2021-07-28" \
-H "User-Agent: Mozilla/5.0"
```
### Python
```python
import http.client
import json
def get_user(token, user_id):
"""GET /users/{userId} — Get user details by ID."""
conn = http.client.HTTPSConnection("services.leadconnectorhq.com", timeout=30)
conn.request("GET", f"/users/{user_id}", "", {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Version": "2021-07-28",
"User-Agent": "Mozilla/5.0"
})
res = conn.getresponse()
data = json.loads(res.read().decode())
conn.close()
if res.status == 200:
return data.get("user", data)
return {"error": res.status, "message": data.get("message", str(data))}
# ── Usage ──
user = get_user(token="pit-xxxxxxxx", user_id="0IHuJvc2ofPAAA8GzTRi")
print(user["firstName"], user["email"], user["permissions"])
```
### Response `200`
```json
{
"id": "0IHuJvc2ofPAAA8GzTRi",
"name": "John Deo",
"firstName": "John",
"lastName": "Deo",
"email": "john@deo.com",
"phone": "+1 808-868-8888",
"extension": "",
"permissions": {...},
"scopes": "...",
"roles": {...},
"deleted": false,
"lcPhone": {"locationId": "+1234556677"},
"platformLanguage": "en_US"
}
```
---
## 3. PUT /users/{userId} — Update User
Actualiza datos del usuario. **Todos los campos son opcionales** — solo envía lo que necesites cambiar.
### cURL — Cambiar permisos y scopes
```bash
curl -X PUT https://services.leadconnectorhq.com/users/{userId} \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Version: 2021-07-28" \
-H "User-Agent: Mozilla/5.0" \
-d '{
"permissions": {"contactsEnabled": true, "opportunitiesEnabled": true},
"scopes": ["contacts.write", "opportunities.write"]
}'
```
### cURL — Mover usuario a otra location
```bash
curl -X PUT https://services.leadconnectorhq.com/users/{userId} \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Version: 2021-07-28" \
-H "User-Agent: Mozilla/5.0" \
-d '{"locationIds": ["LOC_ID_NUEVA"]}'
```
### Python
```python
import http.client
import json
def update_user(token, user_id, **kwargs):
"""
PUT /users/{userId} — Update user fields.
All kwargs are optional. Only send what you want to change.
Examples:
update_user(token, uid, permissions={"contactsEnabled": True})
update_user(token, uid, location_ids=["LOC_A", "LOC_B"])
update_user(token, uid, password="new_password")
update_user(token, uid, scopes=["contacts.write"])
update_user(token, uid, role="admin", type="account")
"""
# Filter out None values
payload = {k: v for k, v in kwargs.items() if v is not None}
if not payload:
return {"error": "No fields to update"}
body = json.dumps(payload).encode()
conn = http.client.HTTPSConnection("services.leadconnectorhq.com", timeout=30)
conn.request("PUT", f"/users/{user_id}", body, {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Version": "2021-07-28",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"
})
res = conn.getresponse()
data = json.loads(res.read().decode())
conn.close()
if res.status == 200:
return data.get("user", data)
return {"error": res.status, "message": data.get("message", str(data))}
# ── Usage examples ──
# Example 1: Update permissions and scopes
update_user(
token="pit-xxxxxxxx",
user_id="0IHuJvc2ofPAAA8GzTRi",
permissions={
"contactsEnabled": True,
"opportunitiesEnabled": True,
"conversationsEnabled": True,
"assignedDataOnly": True
},
scopes=["contacts.write", "opportunities.write", "conversations.write"]
)
# Example 2: Move user to a different location
update_user(
token="pit-xxxxxxxx",
user_id="0IHuJvc2ofPAAA8GzTRi",
location_ids=["U0S0QntXgSOz9Fx18Db4"] # ZACATEPEC
)
# Example 3: Change role and type
update_user(
token="pit-xxxxxxxx",
user_id="0IHuJvc2ofPAAA8GzTRi",
role="admin",
type="account"
)
# Example 4: Reset password
update_user(
token="pit-xxxxxxxx",
user_id="0IHuJvc2ofPAAA8GzTRi",
password="new_secure_password"
)
```
### Campos actualizables
| Campo | Tipo | Deprecado | Descripción |
|-------|------|-----------|-------------|
| `firstName` | string | | Nombre |
| `lastName` | string | | Apellido |
| `email` | string | ⚠️ SÍ | Ya no soportado por seguridad |
| `password` | string | | Nueva contraseña |
| `phone` | string | | Teléfono |
| `type` | string | | `account` / `agency` |
| `role` | string | | `admin` / `user` / `agency_admin` |
| `companyId` | string | | ID de agencia (requerido para agency-level) |
| `locationIds` | string[] | | Reasignar sucursales |
| `permissions` | object | | 39 flags booleanos |
| `scopes` | string[] | | 189 scopes OAuth |
| `scopesAssignedToOnly` | string[] | | Scopes asignados |
| `profilePhoto` | string | | URL de foto |
| `twilioPhone` | object | | Números inbound — semántica replace |
| `platformLanguage` | string | | `es`, `en_US`, etc. |
---
## 4. DELETE /users/{userId} — Delete User
Soft delete asíncrono. El usuario se encola para eliminación y toma efecto en minutos.
### cURL
```bash
curl -X DELETE https://services.leadconnectorhq.com/users/{userId} \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Version: 2021-07-28" \
-H "User-Agent: Mozilla/5.0"
```
### Python
```python
import http.client
import json
def delete_user(token, user_id):
"""DELETE /users/{userId} — Soft delete (async, takes minutes)."""
conn = http.client.HTTPSConnection("services.leadconnectorhq.com", timeout=30)
conn.request("DELETE", f"/users/{user_id}", "", {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Version": "2021-07-28",
"User-Agent": "Mozilla/5.0"
})
res = conn.getresponse()
data = json.loads(res.read().decode())
conn.close()
return data # {"succeded": true, "message": "Queued deleting user..."}
# ── Usage ──
result = delete_user(token="pit-xxxxxxxx", user_id="0IHuJvc2ofPAAA8GzTRi")
print(result["message"]) # "Queued deleting user with e-mail john@deo.com..."
```
### Response `200`
```json
{
"succeded": true,
"message": "Queued deleting user with e-mail john@deo.com and name John Deo. Will take effect in a few minutes."
}
```
---
## 5. GET /users/search — Search Users
Búsqueda avanzada con filtros por companyId, locationId, nombre, email, rol, tipo, IDs específicos, y ordenamiento.
### Parameters
| Name | Type | Required | Description |
|------|------|----------|-------------|
| `Version` | string | ✅ | API Version header |
| `companyId` | string | ✅ | ID de la agencia |
| `query` | string | | Búsqueda por nombre, email o teléfono |
| `locationId` | string | | Filtrar por location |
| `type` | string | | `agency`, `account` |
| `role` | string | | `admin`, `user`, `agency_admin` |
| `ids` | string | | IDs separados por coma |
| `skip` | string | | Paginación offset (default: 0) |
| `limit` | string | | Page size (default: 25) |
| `sort` | string | | Campo de orden: `dateAdded` |
| `sortDirection` | string | | `asc` o `desc` |
### cURL
```bash
curl -X GET "https://services.leadconnectorhq.com/users/search?companyId=5DP41231LkQsiKESj6rh&locationId=C2QujeCh8ZnC7al2InWR&query=John&limit=10&sort=dateAdded&sortDirection=desc" \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Version: 2021-07-28" \
-H "User-Agent: Mozilla/5.0"
```
### Python
```python
import http.client
import json
import urllib.parse
def search_users(token, company_id, query=None, location_id=None,
user_type=None, role=None, ids=None, skip=0, limit=25,
sort="dateAdded", sort_direction="desc"):
"""GET /users/search — Advanced user search."""
params = {
"companyId": company_id,
"skip": str(skip),
"limit": str(limit),
"sort": sort,
"sortDirection": sort_direction
}
if query:
params["query"] = query
if location_id:
params["locationId"] = location_id
if user_type:
params["type"] = user_type
if role:
params["role"] = role
if ids:
params["ids"] = ids
qs = urllib.parse.urlencode(params)
conn = http.client.HTTPSConnection("services.leadconnectorhq.com", timeout=30)
conn.request("GET", f"/users/search?{qs}", "", {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Version": "2021-07-28",
"User-Agent": "Mozilla/5.0"
})
res = conn.getresponse()
data = json.loads(res.read().decode())
conn.close()
if res.status == 200:
return data # {"users": [...], "count": N}
return {"error": res.status, "message": data.get("message", str(data))}
# ── Usage examples ──
# Find all users in a location
users = search_users(token="pit-xxxxxxxx", company_id="ve9EPM428h8vShlRW1KT",
location_id="U0S0QntXgSOz9Fx18Db4")
print(f"Found {users['count']} users in ZACATEPEC")
# Search by name
users = search_users(token="pit-xxxxxxxx", company_id="ve9EPM428h8vShlRW1KT",
query="Martin Macedo")
for u in users.get("users", []):
print(f" {u['id']}: {u['name']} ({u['email']})")
# Filter by role
admins = search_users(token="pit-xxxxxxxx", company_id="ve9EPM428h8vShlRW1KT",
role="admin", limit=50)
```
### Response `200`
```json
{
"users": [
{
"id": "0IHuJvc2ofPAAA8GzTRi",
"name": "John Deo",
"firstName": "John",
"lastName": "Deo",
"email": "john@deo.com",
"phone": "+1 808-868-8888",
"extension": "",
"permissions": {...},
"scopes": "...",
"roles": {...},
"deleted": false,
"lcPhone": {"locationId": "+1234556677"},
"platformLanguage": "en_US"
}
],
"count": 1
}
```
---
## 6. POST /users/search/filter-by-email — Filter by Email
Filtra usuarios por array de emails. Ideal para verificar duplicados antes de crear.
### cURL
```bash
curl -X POST https://services.leadconnectorhq.com/users/search/filter-by-email \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-H "Version: 2021-07-28" \
-H "User-Agent: Mozilla/5.0" \
-d '{
"companyId": "ve9EPM428h8vShlRW1KT",
"emails": ["john@deo.com", "jane@deo.com"],
"includeDeleted": false
}'
```
### Python
```python
import http.client
import json
def filter_users_by_email(token, company_id, emails, include_deleted=False):
"""
POST /users/search/filter-by-email — Check if emails already exist.
Args:
emails: List of email strings to check
include_deleted: Include soft-deleted users
Returns:
List of users matching the emails
"""
body = json.dumps({
"companyId": company_id,
"emails": emails,
"includeDeleted": include_deleted
}).encode()
conn = http.client.HTTPSConnection("services.leadconnectorhq.com", timeout=30)
conn.request("POST", "/users/search/filter-by-email", body, {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Version": "2021-07-28",
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0"
})
res = conn.getresponse()
data = json.loads(res.read().decode())
conn.close()
if res.status == 200:
return data # Array or {"users": [...]}
return {"error": res.status, "message": data.get("message", str(data))}
# ── Usage: Check for duplicate before creating ──
def email_exists(token, company_id, email):
"""Returns True if a user with this email already exists."""
result = filter_users_by_email(token, company_id, [email])
users = result.get("users", result if isinstance(result, list) else [])
return len(users) > 0
existing = email_exists(token="pit-xxxxxxxx", company_id="ve9EPM428h8vShlRW1KT",
email="abejorrito79@gmail.com")
if existing:
print("⚠️ Email already taken — cannot create user")
else:
print("✅ Email available — safe to create")
```
### Response 200
Devuelve array de usuarios que coinciden con los emails proporcionados.
---
## 7. GET /users/ — Get User by Location ⚠️ DEPRECADO
**No usar.** Reemplazado por `GET /users/search` con query param `locationId`.
```bash
# ❌ Deprecado
GET /users/?locationId=LOC_ID
# ✅ Correcto
GET /users/search?locationId=LOC_ID&companyId=COMPANY_ID
```
---
## 8. Response Codes
| Código | Significado | Schema |
|--------|-------------|--------|
| `200` | OK (GET, PUT, DELETE) | `UserSuccessfulResponseDto` / `DeleteUserSuccessfulResponseDto` |
| `201` | Created (POST) | `UserSuccessfulResponseDto` |
| `400` | Bad Request | `BadRequestDTO` |
| `401` | Unauthorized — token sin scope | `UnauthorizedDTO` |
| `422` | Unprocessable — email duplicado, locationIds inválido | `UnprocessableDTO` |
---
## 9. Scope Requirements
| Operación | Scope |
|-----------|-------|
| GET (search, get by id, filter by email) | `users.readonly` |
| POST (create) | `users.write` |
| PUT (update) | `users.write` |
| DELETE | `users.write` |
---
## 10. Notas importantes
1. **Email no se puede actualizar** — el campo `email` en PUT está deprecado por seguridad
2. **Delete es asíncrono** — la respuesta confirma encolado, no eliminación inmediata
3. **TwilioPhone tiene semántica replace** — si lo envías, reemplaza todo el mapa; no hace merge
4. **Scopes vacíos deshabilitan todo**`"scopes": []` quita todos los permisos
5. **companyId es obligatorio** en search y filter-by-email — se obtiene del `GET /locations/{id}`
6. **locationIds acepta array** — un usuario puede pertenecer a múltiples sucursales simultáneamente
7. **Versión 2021-07-28** — todos los endpoints usan esta versión; algunos aceptan 2023-02-21
---
## 11. Python Helper — Clase completa
```python
import http.client
import json
import urllib.parse
class GHLUsersAPI:
"""Complete wrapper for GoHighLevel Users API."""
BASE = "services.leadconnectorhq.com"
def __init__(self, token):
self.token = token
self.headers = {
"Accept": "application/json",
"Authorization": f"Bearer {token}",
"Version": "2021-07-28",
"User-Agent": "Mozilla/5.0"
}
def _request(self, method, path, body=None):
"""Generic HTTP request helper."""
conn = http.client.HTTPSConnection(self.BASE, timeout=30)
conn.request(method, path, body or "", self.headers)
res = conn.getresponse()
data = json.loads(res.read().decode())
conn.close()
return res.status, data
def create(self, company_id, first_name, last_name, email, password,
location_ids, role="user", type_="account", **kwargs):
"""POST /users/ — Create user."""
payload = {
"companyId": company_id, "firstName": first_name,
"lastName": last_name, "email": email, "password": password,
"type": type_, "role": role, "locationIds": location_ids,
"platformLanguage": kwargs.get("language", "es")
}
for k in ("phone", "permissions", "scopes", "profilePhoto", "twilioPhone"):
if k in kwargs and kwargs[k] is not None:
payload[k] = kwargs[k]
status, data = self._request("POST", "/users/",
json.dumps(payload).encode())
if status == 201:
return data.get("user", data)
raise Exception(f"Create failed: {status} {data.get('message', data)}")
def get(self, user_id):
"""GET /users/{userId} — Get user."""
status, data = self._request("GET", f"/users/{user_id}")
if status == 200:
return data.get("user", data)
raise Exception(f"Get failed: {status}")
def update(self, user_id, **kwargs):
"""PUT /users/{userId} — Update user fields."""
payload = {k: v for k, v in kwargs.items() if v is not None}
if not payload:
raise Exception("No fields to update")
status, data = self._request("PUT", f"/users/{user_id}",
json.dumps(payload).encode())
if status == 200:
return data.get("user", data)
raise Exception(f"Update failed: {status} {data.get('message', data)}")
def delete(self, user_id):
"""DELETE /users/{userId} — Soft delete."""
status, data = self._request("DELETE", f"/users/{user_id}")
if status == 200:
return data
raise Exception(f"Delete failed: {status}")
def search(self, company_id, **kwargs):
"""GET /users/search — Search users."""
params = {"companyId": company_id, "skip": "0", "limit": "25"}
for k in ("query", "locationId", "type", "role", "ids", "sort",
"sortDirection"):
if k in kwargs and kwargs[k] is not None:
params[k] = str(kwargs[k])
qs = urllib.parse.urlencode(params)
status, data = self._request("GET", f"/users/search?{qs}")
if status == 200:
return data
raise Exception(f"Search failed: {status}")
def filter_by_email(self, company_id, emails, include_deleted=False):
"""POST /users/search/filter-by-email — Filter by email list."""
payload = {"companyId": company_id, "emails": emails,
"includeDeleted": include_deleted}
status, data = self._request("POST", "/users/search/filter-by-email",
json.dumps(payload).encode())
if status == 200:
return data
raise Exception(f"Filter failed: {status}")
# ── Complete usage example ──
api = GHLUsersAPI(token="pit-xxxxxxxx")
# 1. Check if email exists
try:
existing = api.filter_by_email("ve9EPM428h8vShlRW1KT", ["test@example.com"])
print(f"Email check: {len(existing.get('users', []))} found")
except:
print("Email available")
# 2. Create user
user = api.create(
company_id="ve9EPM428h8vShlRW1KT",
first_name="Martin", last_name="Macedo",
email="abejorrito79@gmail.com", password="********",
phone="+527775610738",
location_ids=["U0S0QntXgSOz9Fx18Db4"],
role="user",
permissions={"contactsEnabled": True, "opportunitiesEnabled": True},
scopes=["contacts.write", "opportunities.write"]
)
print(f"Created: {user['id']}")
# 3. Update permissions
api.update(user["id"],
permissions={"contactsEnabled": True, "opportunitiesEnabled": True,
"conversationsEnabled": True, "assignedDataOnly": True},
scopes=["contacts.write", "opportunities.write", "conversations.write"])
# 4. Search users in location
result = api.search("ve9EPM428h8vShlRW1KT",
locationId="U0S0QntXgSOz9Fx18Db4")
print(f"Users in ZACATEPEC: {result['count']}")
# 5. Delete user (if needed)
# api.delete(user["id"])
```
@@ -0,0 +1,11 @@
#Como mapear datos por location
Hacer peticiones get de schema para obtener toda la información con https://services.leadconnectorhq.com/objects/ empezando con contacts y opportunities. Siguiente fase es hacer para cada campo obtenido de la petición anterior https://services.leadconnectorhq.com/objects/:key para todos los campos personalizados haciendo corrrecto mapeo de todos los campos con fieldkey haciendo fallback en el nombre para detectar anomalias.
Con lo anterior ya podremos identificar todos los campos haciendo el filtrado por nombre o por fieldkey (en caso de petición del usuario.) Esto surje de la necesidad de que las peticiones tipo get contact y get opportunnity no proporcionan los fieldkeys de los campos personalizados porque solo da el id del campo personalizado y el value.
# Clientes
Algunos clientes utilizan el mismo teléfono para hacer más de un registro de formulario, a veces incluso el mismo email pero esto ultimo es menos probable.
# Consideraciones principales para sincronización de contactos y oportunidades.
## Contactos
-Hacer primer busqueda por teléfono, email y nombre en ese orden. Normalizar texto de nombre para evitar errores de acentos, mayusculas y minusculas.
+637
View File
@@ -0,0 +1,637 @@
# Documentación API Go High Level (GHL) — Monte Providencia
> Versión de API: `2021-07-28`
> Base URL: `https://services.leadconnectorhq.com`
> Autenticación: `Bearer <API_TOKEN>` en header
---
## Índice
1. [Autenticación y Headers](#1-autenticación)
2. [Contactos](#2-contactos)
3. [Oportunidades](#3-oportunidades)
4. [Pipelines](#4-pipelines)
5. [Campos Personalizados](#5-campos-personalizados)
6. [Tags](#6-tags)
7. [Workflows](#7-workflows)
8. [Cuentas y Locations](#8-cuentas--locations)
9. [Búsquedas (Search)](#9-búsquedas-search)
10. [Errores Comunes](#10-errores-comunes)
11. [Apéndice: Códigos de Sucursales](#11-apéndice-códigos-de-sucursales)
---
## 1. Autenticación
### Headers requeridos en toda petición
```
Authorization: Bearer <API_TOKEN>
Version: 2021-07-28
Accept: application/json
```
### Cómo obtener el token
El token es el `API_token` asociado a cada Location ID. Se guarda en:
- `/data/workspace/agents/ghl/monte-providencia-accounts.csv`
- Formato: `Location_ID / API_TOKEN`
### Ejemplo con curl
```bash
curl -s -L "https://services.leadconnectorhq.com/contacts/" \
-H "Authorization: Bearer TU_TOKEN_AQUI" \
-H "Version: 2021-07-28"
```
---
## 2. Contactos
### 2.1 Obtener todos los contactos (GET /contacts/)
```
GET https://services.leadconnectorhq.com/contacts/?locationId={LOCATION_ID}&limit={1-100}
```
**Parámetros query:**
| Parámetro | Tipo | Descripción |
|---|---|---|
| `locationId` | string | **Requerido.** El Location ID de la cuenta |
| `limit` | int | 1-100 (máximo 100, API lo rechaza si es mayor) |
| `startAfter` | string | Cursor para paginar — valor de `nextPageUrl` |
| `startAfterId` | string | ID del último contacto para paginar |
**Nota sobre paginación:** La API devuelve `meta.nextPageUrl` con la URL completa de la siguiente página. No usa `skip`/offset. El campo `startAfter` se construye a partir del cursor en `nextPageUrl`.
```bash
# Primera página
curl -s "https://services.leadconnectorhq.com/contacts/?locationId=GBKkBpCmKu2QmloKFHy3&limit=100" \
-H "Authorization: Bearer TOKEN" -H "Version: 2021-07-28"
# Segunda página (usar nextPageUrl completo)
curl -s "https://services.leadconnectorhq.com/contacts/?locationId=GBKkBpCmKu2QmloKFHy3&limit=100&startAfter={CURSOR}" \
-H "Authorization: Bearer TOKEN" -H "Version: 2021-07-28"
```
**Respuesta:**
```json
{
"contacts": [
{
"id": "CKFO2IvIw41sOGOtCeFi",
"firstName": "Nicolas",
"lastName": "Tielve",
"email": "nicolas@ejemplo.com",
"phone": "+525531320553",
"locationId": "GbKkBpCmKu2QmloKFHy3",
"tags": ["sucursal", "follow-up"],
"customFields": [
{ "id": "N1apTH4TKBZ6PZqfs4L6", "value": "Ejército Nacional" }
],
"type": "contact",
"dateAdded": "2026-04-29T00:00:00Z",
"updatedAt": "2026-04-29T00:00:00Z"
}
],
"meta": {
"nextPageUrl": "https://services.leadconnectorhq.com/contacts/?locationId=...&startAfter=..."
}
}
```
### 2.2 Obtener un contacto por ID (GET /contacts/{id})
```
GET https://services.leadconnectorhq.com/contacts/{CONTACT_ID}?locationId={LOCATION_ID}
```
```bash
curl -s "https://services.leadconnectorhq.com/contacts/CKFO2IvIw41sOGOtCeFi?locationId=GbKkBpCmKu2QmloKFHy3" \
-H "Authorization: Bearer TOKEN" -H "Version: 2021-07-28"
```
### 2.3 Crear contacto (POST /contacts/)
```
POST https://services.leadconnectorhq.com/contacts/
```
**Body (JSON):**
```json
{
"firstName": "Juan",
"lastName": "Pérez",
"email": "juan@ejemplo.com",
"phone": "+525512345678",
"locationId": "GbKkBpCmKu2QmloKFHy3",
"tags": ["lead", "web"],
"customFields": [
{ "id": "N1apTH4TKBZ6PZqfs4L6", "value": "Cancún" }
]
}
```
### 2.4 Actualizar contacto (PUT /contacts/{id})
```
PUT https://services.leadconnectorhq.com/contacts/{CONTACT_ID}
```
```json
{
"firstName": "Juan Actualizado",
"tags": ["lead", "web", "actualizado"],
"customFields": [
{ "id": "N1apTH4TKBZ6PZqfs4L6", "value": "Cancún" }
]
}
```
### 2.5 Eliminar contacto (DELETE /contacts/{id})
```
DELETE https://services.leadconnectorhq.com/contacts/{CONTACT_ID}?locationId={LOCATION_ID}
```
### 2.6 Agregar tag a contacto (POST /contacts/{id}/tags)
```
POST https://services.leadconnectorhq.com/contacts/{CONTACT_ID}/tags
```
Body:
```json
{
"tags": ["nuevo-tag"]
}
```
### 2.7 Eliminar tag de contacto (DELETE /contacts/{id}/tags/{tag})
```
DELETE https://services.leadconnectorhq.com/contacts/{CONTACT_ID}/tags/{TAG_NAME}
```
---
## 3. Oportunidades
### 3.1 Buscar oportunidades (POST /opportunities/search)
> ⚠️ **IMPORTANTE:** `GET /opportunities/` retorna vacío o 0 resultados. SIEMPRE usar `POST /opportunities/search`.
```
POST https://services.leadconnectorhq.com/opportunities/search
```
**Body:**
```json
{
"locationId": "GbKkBpCmKu2QmloKFHy3",
"limit": 100
}
```
**Respuesta:**
```json
{
"total": 184,
"opportunities": [
{
"id": "abc123",
"name": "Venta BMW 2024",
"status": "open",
"pipelineId": "NipUIm51OGRx5xGDifGT",
"pipelineStageId": "stage_xyz",
"monetaryValue": 500000,
"contactId": "CKFO2IvIw41sOGOtCeFi",
"assignedTo": "user_123",
"dateAdded": "2026-04-29T00:00:00Z"
}
]
}
```
### 3.2 Obtener oportunidad por ID (GET /opportunities/{id})
```
GET https://services.leadconnectorhq.com/opportunities/{OPP_ID}?locationId={LOCATION_ID}
```
### 3.3 Crear oportunidad (POST /opportunities/)
```
POST https://services.leadconnectorhq.com/opportunities/
```
```json
{
"name": "Nueva Oportunidad",
"locationId": "GbKkBpCmKu2QmloKFHy3",
"pipelineId": "NipUIm51OGRx5xGDifGT",
"pipelineStageId": "stage_xyz",
"contactId": "CKFO2IvIw41sOGOtCeFi",
"monetaryValue": 100000,
"assignedTo": "user_id",
"status": "open"
}
```
### 3.4 Mover oportunidad a otro pipeline (PUT /opportunities/{id})
> Para cambiar una oportunidad de pipeline, se usa `PUT` con el nuevo `pipelineId` y `pipelineStageId`.
```
PUT https://services.leadconnectorhq.com/opportunities/{OPP_ID}
```
```json
{
"pipelineId": "NipUIm51OGRx5xGDifGT",
"pipelineStageId": "stage_destino"
}
```
**Nota:** Para obtener los pipelineStageId disponibles, primero hay que recuperar los pipelines (ver sección 4).
### 3.5 Eliminar oportunidad (DELETE /opportunities/{id})
```
DELETE https://services.leadconnectorhq.com/opportunities/{OPP_ID}?locationId={LOCATION_ID}
```
---
## 4. Pipelines
### 4.1 Obtener pipelines (GET /pipelines/)
> ⚠️ **NOTA:** Esta llamada frecuentemente retorna `[]` para muchas cuentas. Cuando esto ocurre, los `pipelineId` se pueden obtener desde las oportunidades existentes usando `POST /opportunities/search`.
```
GET https://services.leadconnectorhq.com/pipelines/?locationId={LOCATION_ID}
```
```json
{
"pipelines": [
{
"id": "NipUIm51OGRx5xGDifGT",
"name": "Pipeline Principal",
"locationId": "GbKkBpCmKu2QmloKFHy3",
"stages": [
{
"id": "stage_abc",
"name": "Nuevo Lead",
"order": 1
},
{
"id": "stage_def",
"name": "Contacto Hecho",
"order": 2
}
]
}
]
}
```
### 4.2 Obtener pipeline específico (GET /pipelines/{pipelineId})
```
GET https://services.leadconnectorhq.com/pipelines/{PIPELINE_ID}?locationId={LOCATION_ID}
```
---
## 5. Campos Personalizados (Custom Fields)
### 5.1 Obtener todos los campos personalizados (GET /locations/{locationId}/customFields)
```
GET https://services.leadconnectorhq.com/locations/{LOCATION_ID}/customFields
```
```json
{
"customFields": [
{
"id": "N1apTH4TKBZ6PZqfs4L6",
"name": "Sucursal",
"dataType": "SINGLE_OPTIONS",
"options": ["Cancún", "Texcoco", "Ejército Nacional", ...]
},
{
"id": "fRmSueH3BcHX8bCiybyC",
"name": "Canal de Origen",
"dataType": "SINGLE_OPTIONS"
},
{
"id": "wtQb2PtY2GwJ4GI08BYG",
"name": "Fuente de Prospecto",
"dataType": "SINGLE_OPTIONS"
}
]
}
```
### 5.2 Obtener las opciones de un campo SINGLE_OPTIONS
Las opciones vienen en el campo `options` del custom field.
### ⚠️ IDs de campos diferentes por cuenta
> **CRÍTICO:** Cada Location (sucursal) tiene sus propios IDs de campos personalizados. Un ID de campo en la cuenta Marca NO es el mismo en las sucursales.
>
> Ejemplo — Campo "Sucursal":
> - Monte Providencia Marca: `N1apTH4TKBZ6PZqfs4L6`
> - 85974 - MP - Ejército Nacional: `pmrGTW3tIa7oz7rQJMVx`
> - 85932 - MP - La Viga: `Wj1iYdPv98dYxvE8sDF8`
>
> Para auditar el campo Sucursal correctamente, primero hay que obtener los IDs de cada sucursal consultando `/locations/{id}/customFields`.
### 5.3 Crear campo personalizado (POST /locations/{locationId}/customFields)
```
POST https://services.leadconnectorhq.com/locations/{LOCATION_ID}/customFields
```
```json
{
"name": "Mi Campo Nuevo",
"dataType": "SINGLE_OPTIONS",
"options": ["Opción 1", "Opción 2"],
"placeholder": "Selecciona..."
}
```
### 5.4 Tipos de campo (dataType)
| dataType | Descripción |
|---|---|
| `SINGLE_OPTIONS` | Selector dropdown de una opción |
| `MULTIPLE_OPTIONS` | Checkboxes (múltiples opciones) |
| `TEXT` | Campo de texto libre |
| `LARGE_TEXT` | Textarea (texto largo) |
| `PHONE_NUMBER` | Número telefónico |
| `EMAIL_ADDRESS` | Correo electrónico |
| `MONETORY` | Monto en dinero |
| `DATE` | Fecha |
| `CHECKBOX` | Boolean (sí/no) |
---
## 6. Tags
### 6.1 Obtener todos los tags (GET /locations/{locationId}/tags)
```
GET https://services.leadconnectorhq.com/locations/{LOCATION_ID}/tags
```
```json
{
"tags": [
{ "id": "tag_123", "name": "sucursal", "contactCount": 45 },
{ "id": "tag_456", "name": "lead", "contactCount": 120 }
]
}
```
### 6.2 Crear tag (POST /locations/{locationId}/tags)
```
POST https://services.leadconnectorhq.com/locations/{LOCATION_ID}/tags
```
Body:
```json
{ "name": "nuevo-tag" }
```
### 6.3 Eliminar tag (DELETE /locations/{locationId}/tags/{tagName})
```
DELETE https://services.leadconnectorhq.com/locations/{LOCATION_ID}/tags/{TAG_NAME}
```
### 6.4 Eliminar tag de contacto específico (DELETE /contacts/{id}/tags/{tagName})
```
DELETE https://services.leadconnectorhq.com/contacts/{CONTACT_ID}/tags/{TAG_NAME}?locationId={LOCATION_ID}
```
### 6.5 Agregar tag a contacto (POST /contacts/{id}/tags)
```
POST https://services.leadconnectorhq.com/contacts/{CONTACT_ID}/tags
```
Body:
```json
{ "tags": ["nuevo-tag"] }
```
---
## 7. Workflows
### 7.1 Obtener workflows (GET /workflows/)
```
GET https://services.leadconnectorhq.com/workflows/
```
**Parámetros query:**
| Parámetro | Descripción |
|---|---|
| `locationId` | **Requerido.** Location ID |
| `limit` | 1-100 |
| `status` | `active`, `inactive`, `draft` |
```json
{
"workflows": [
{
"id": "wf_abc123",
"name": "Sincronizar Contacto Creado",
"status": "active",
"trigger": "contact.created",
"createdAt": "2026-01-01T00:00:00Z"
}
]
}
```
### 7.2 Obtener un workflow específico (GET /locations/{locationId}/workflows/{workflowId})
```
GET https://services.leadconnectorhq.com/locations/{LOCATION_ID}/workflows/{WORKFLOW_ID}
```
### 7.3 Activar/Desactivar workflow (PUT /locations/{locationId}/workflows/{workflowId})
```
PUT https://services.leadconnectorhq.com/locations/{LOCATION_ID}/workflows/{WORKFLOW_ID}
```
```json
{
"status": "active"
}
```
### 7.4 Estados de workflow
| Status | Significado |
|---|---|
| `active` | Publicada y corriendo |
| `inactive` | Pausada |
| `draft` | Borrador — no está publicada |
### 7.5 Triggers comunes
Los triggers disponibles varían según la configuración de GHL. Comunes:
- `contact.created` — Se ejecuta al crear contacto
- `contact.updated` — Se ejecuta al actualizar contacto
- `tag.added` — Se ejecuta al agregar tag
- `form.submission` — Se ejecuta al recibir formulario
---
## 8. Cuentas y Locations
### 8.1 Location ID de Monte Providencia (Marca)
| Campo | Valor |
|---|---|
| Location ID | `GbKkBpCmKu2QmloKFHy3` |
| Token | `pit-4e4266f8-97ac-4150-a971-cc9158809640` |
### 8.2 Obtener información de location (GET /locations/{locationId})
```
GET https://services.leadconnectorhq.com/locations/{LOCATION_ID}
```
### 8.3 Listar usuarios (GET /locations/{locationId}/users)
```
GET https://services.leadconnectorhq.com/locations/{LOCATION_ID}/users
```
---
## 9. Búsquedas (Search)
### 9.1 Búsqueda de contactos con filtros (POST /contacts/search)
> ⚠️ **NOTA:** Esta endpoint devuelve 404 o errores para muchas cuentas. Usar `GET /contacts/` con paginación en su lugar.
```
POST https://services.leadconnectorhq.com/contacts/search
```
```json
{
"locationId": "GbKkBpCmKu2QmloKFHy3",
"limit": 100,
"query": "Nicolas"
}
```
### 9.2 Búsqueda de oportunidades (POST /opportunities/search)
> Esta es la forma correcta y principal de obtener oportunidades. **No usar GET.**
```
POST https://services.leadconnectorhq.com/opportunities/search
```
```json
{
"locationId": "GbKkBpCmKu2QmloKFHy3",
"limit": 100,
"pipelineId": "NipUIm51OGRx5xGDifGT"
}
```
---
## 10. Errores Comunes
| Código | Causa | Solución |
|---|---|---|
| `400` | Body malformado | Verificar JSON, comas, comillas |
| `401` | Token inválido o expirado | Verificar el `Bearer <token>` |
| `404` | Recurso no encontrado | Verificar el ID del recurso |
| `422` | Datos inválidos | Revisar el formato del body |
| `429` | Rate limit | Esperar 5-10 segundos entre requests |
| `500` | Error interno del servidor | Reintentar más tarde |
| `limit > 100` | Límite de página excedido | Usar `limit=100` máximo |
| `[]` vacío en GET /pipelines/ | Endpoint no soportado | Usar POST /opportunities/search para obtener pipelineIds |
| `[]` vacío en GET /contacts/ | Rate limit o account empty | Verificar token y Location ID |
### Rate Limits
- No hay un rate limit documentado oficial, pero se recomienda:
- **0.5-1 segundo** entre requests normales
- **5-10 segundos** después de errores 429
- Paginación: continuar solo cuando `nextPageUrl` exista
---
## 11. Apéndice: Códigos de Sucursales
### Monte Providencia — CSV de cuentas
Archivo: `/data/workspace/agents/ghl/monte-providencia-accounts.csv`
### Sucursales con IDs de campo Sucursal (Custom Field)
> Cada sucursal tiene su propio ID para el campo "Sucursal". Usar la tabla del CSV de cuentas para obtener el ID correcto.
| Sucursal | Location ID | Token (primeros 10 chars) |
|---|---|---|
| Monte Providencia (Marca) | GbKkBpCmKu2QmloKFHy3 | `pit-4e4266f8` |
| 0001 - MP -Qro DEMO | Z64WQKORPVwXb5mn68Ef | `pit-8215e76f` |
| Monte Providencia DEMO | Vf7qQl3L9vakJ8hDtQ8e | `pit-f162f285` |
| 85930 - MP - TULYEHUALCO | rQYjjwsGnjEGagskOxix | `pit-8001d42c` |
| 85931 - MP - Marina Nacional | HvDw9Eg3rjrwkbQJXqfi | `pit-155a2c8f` |
| 85932 - MP - La Viga | fKn9SaXZoKcjjLryg10v | `pit-350d963b` |
| 85957 - MP - PINOTEPA | 7H91g95hhLKwIUqSk0Rg | `pit-f68877be` |
| 85958 - MP - POCHUTLA | HvyNhH2IOe9ByeZrRo0N | `pit-0fb64a32` |
| 85974 - MP - Ejército Nacional | nF1uEaYB3mCK5em9bPn2 | `pit-dbdaabeb` |
| 85976 - MP - Cancún | uJEn2iuUficuml9zxAnt | `pit-7a23a1d8` |
| 85954 - MP - Izcalli | r0fiuXv6zQnFyXJW2SWU | `pit-8e8596a8` |
> Para la lista completa, consultar el archivo CSV.
---
## 12. Scripts de Auditoría Existentes
### Ubicación: `/data/workspace/agents/ghl/scripts/`
| Script | Descripción |
|---|---|
| `move_opportunities_pipeline.py` | Mueve oportunidades de un pipeline a otro |
| `check_multi_pipeline.py` | Detecta cuentas con múltiples pipelines |
| `find_contacts_without_sucursal.py` | Encuentra contactos sin campo Sucursal |
| `monitor_no_email.py` | Detecta contactos sin email en todas las cuentas (cron) |
### Monitoreo activo (cron jobs):
| Cron | Frecuencia | Qué hace |
|---|---|---|
| `GHL Monitor No Email` | Cada 30 min | Reporta nuevos contactos sin email |
| `Health Check Workflows` | Cada 30 min | Detecta workflows faltantes/inactivos |
Reportes: `/data/workspace/agents/ghl/reports/`
---
*Documentación generada en mayo 2026. Algunas endpoints pueden variar según el plan y configuración de GHL.*
@@ -0,0 +1,51 @@
SUCURSAL,TIENDA,CORREO TIENDA,CORREO DM,CORREO RDO,Agente BUCEFALO,Agente BUCEFALO ID,SC BUCEFALO,ID LOCATION BUCEFALO,SC TOKEN BUCEFALO,TIPO DE TIENDA
-,CUENTA PRINCIPAL,-,-,-,-,-,Monte Providencia,GbKkBpCmKu2QmloKFHy3,pit-4e4266f8-97ac-4150-a971-cc9158809640,-
"Atlacomulco, Estado de México",ATLACOMULCO,ezstore85943@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85943 - MP - ATLACOMULCO,XzQ5Wi3RqHwc3AyE8QrI,pit-12986b2f-dda2-44f5-8fd5-1902f8bba377,PARCIAL
"Atizapán, Estado de México",ATIZAPAN,ezstore85934@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,85934 - MP - Atizapán,IE7ci6Ddfk1WvQabEa4q,pit-a263eb39-24b7-47d9-aadf-5ca9b997d15d,PARCIAL
"Altamira, Tamaulipas",ALTAMIRA,ezstore85967@ezcorp.com,cesar.moctezuma@ezcorp.com,sandra.mendoza@ezcorp.com,,,85967 - MP - Altamira,w1mYacmbTLjVwVDFF5Jx,pit-fcd0d3c6-e763-4d21-aac6-c0164ec4ef23,PARCIAL
"Cancún, Quintana Roo",CANCUN,ezstore85976@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85976 - MP - Cancún,uJEn2iuUficuml9zxAnt,pit-7a23a1d8-01a3-486b-81e1-78e4b0f416c1,FULL AUTOS
"Ciudad del Carmen, Campeche",CD CARMEN,ezstore85960@ezcorp.com,yaneth_perez@ezcorp.com,lorena_segura@ezcorp.com,,,85960 - MP - Cd. Carmen,XkduzafvwsrWcEFg6Qlj,pit-d9d5e991-54d8-4c58-90ca-7cabf53247e5,PARCIAL
"Ciudad Satélite, Estado de México",SATELITE,ezstore85971@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85971 - MP - Satélite,R34lUVVpltnB8Z1RqnEB,pit-62df3e5c-55d0-4192-8f3a-06272e0eb1bd,FULL AUTOS
"Chilpancingo, Guerrero",CHILPANCINGO,ezstore85947@ezcorp.com,saul.ortiz@ezcorp.com,roman_gutierrez@ezcorp.com,,,85947 - MP - Chilpancingo,qe3s72MRDhbEWlaFZ2Ko,pit-4fcb095f-504b-4a38-a9ab-f7724ceee00e,PARCIAL
"Cuajimalpa, Ciudad de México",CUAJIMALPA,ezstore85933@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,85933 - MP - CUAJIMALPA,VwDgbGbahFXPSyZmpCzt,pit-c1b5442f-81b5-46a1-818d-e7eae32a4066,PARCIAL
"Cuautla, Morelos",CUAUTLA,ezstore85945@ezcorp.com,saul.ortiz@ezcorp.com,roman_gutierrez@ezcorp.com,,,85945 - MP - Cuautla,arEgADUgzqjK3qH7LXSz,pit-19d774a4-5ad0-4421-be26-5033c4631a2a,PARCIAL
"Ecatepec, Estado de México",ECATEPEC,ezstore85952@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,85952 - MP - ECATEPEC,jB8BGwt9NoMAd3NGKjai,pit-298e19b3-94d4-493d-bc47-d5be3045f5a0,PARCIAL
"El Salado, Ciudad de México",PLAZA EL SALADO,ezstore85956@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,85932 - MP - PLAZA EL SALADO,WLPVTRxg7W074dfzBxZL,pit-ba851b3e-141b-4d90-b4a0-050e83d900a4,PARCIAL
"Huauchinango, Puebla",HUAUCHINANGO,ezstore85968@ezcorp.com,cesar.moctezuma@ezcorp.com,sandra.mendoza@ezcorp.com,,,85968 - MP - HUAUCHINANGO,gtGA3sLqKBNSIAuf5hjx,pit-f510cc8c-b6e9-4b92-aa86-06df58099614,PARCIAL
"Interlomas, Estado de México",INTERLOMAS,ezstore85977@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85977 - MP - Interlomas,2eJPAdEGjC7iPhDDAeoy,pit-a19a7d07-2658-4b5b-b7f6-48ce5b264c9b,FULL AUTOS
"Ixmiquilpan, Hidalgo",IXMIQUILPAN,ezstore85955@ezcorp.com,nancy_godinez2@ezcorp.com,miguel_paredes@ezcorp.com,,,85955 - MP - IXMIQUILPAN,ZvVhdvqBU5K9YLEH0fy3,pit-88ff6484-12d7-4f39-b604-c09aa324afac,PARCIAL
"Izcalli, Estado de México",IZCALLI,ezstore85954@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,85954 - MP - Izcalli,r0fiuXv6zQnFyXJW2SWU,pit-8e8596a8-5323-4f71-8e55-af2cf0f450d1,PARCIAL
"Jojutla, Morelos",JOJUTLA,ezstore85946@ezcorp.com,saul.ortiz@ezcorp.com,roman_gutierrez@ezcorp.com,,,85946 - MP - Jojutla,5qebe8IhFUjlcBvJcqCa,pit-53f52dc0-6089-4caa-aaee-ed17ae8b631c,PARCIAL
"La Viga, Ciudad de México",LA VIGA,ezstore58932@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,58932 - MP - La Viga,fKn9SaXZoKcjjLryg10v,pit-350d963b-952a-49ce-8063-eb28b9826ca1,PARCIAL
"Marina Nacional, Ciudad de México",MARINA NACIONAL,ezstore85931@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,85931 - MP - Marina Nacional,HvDw9Eg3rjrwkbQJXqfi,pit-5f6c7bb9-8a65-41a2-850e-ac79e6f3ad01,PARCIAL
"Miacatlán, Morelos",MIACATLAN,ezstore85948@ezcorp.com,saul.ortiz@ezcorp.com,roman_gutierrez@ezcorp.com,,,85948 - MP - MIACATLAN,RWqOypPx7S6t7MCleu2K,pit-a3ce8126-3409-427f-b703-c77741060b22,PARCIAL
"Miahuatlán, Oaxaca",MIAHUATLAN,ezstore85959@ezcorp.com,jorge_suriano@ezcorp.com,lorena_segura@ezcorp.com,,,85959 - MP - MIAHUATLAN,YGOlLwAl2AeN9JtZ47vV,pit-119dc508-cfe7-4d69-8c89-a5f504fee1b3,PARCIAL
"Morelia, Michoacán",MORELIA 1,ezstore85964@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85964 - MP - Morelia 1,jE41bVhhnb5T505BFm4F,pit-0934169e-ce16-4a4e-a366-36e65edcee87,FULL AUTOS
"Pinotepa, Oaxaca",PINOTEPA,ezstore85957@ezcorp.com,jorge_suriano@ezcorp.com,lorena_segura@ezcorp.com,,,85957 - MP - PINOTEPA,7H91g95hhLKwIUqSk0Rg,pit-f68877be-db7c-4414-afa1-dc434fbba0f0,PARCIAL
"Playa del Carmen, Quintana Roo",VILLAS DEL SOL,ezstore85961@ezcorp.com,patricia_velazquez2@ezcorp.com,lorena_segura@ezcorp.com,,,85961 - MP - VILLAS DEL SOL,nRSeOhlhQ3vyirTKYhPi,pit-8e82f2f6-9e13-400d-af02-4ae61765aab3,PARCIAL
"Pochutla, Oaxaca",POCHUTLA,ezstore85958@ezcorp.com,jorge_suriano@ezcorp.com,lorena_segura@ezcorp.com,,,85958 - MP - POCHUTLA,HvyNhH2IOe9ByeZrRo0N,pit-0fb64a32-5685-4f55-888b-dba78dedf9cd,PARCIAL
"Puebla, Puebla",PUEBLA,ezstore85973@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85973 - MP - Puebla,KEZ7dAhgwzK4uZfMvZuj,pit-7faad038-64a9-4ad7-8a40-9bece2ba4b66,FULL AUTOS
"Querétaro, Querétaro",QUERETARO,ezstore85975@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,Agente DEMO QRO,rZ5YCSBqZRVhDdcSoQvL,85975 - MP - Querétaro,blRZ21GlzgUCA7bl2uVw,pit-9ba01577-ccd7-42c6-a908-ba7594f0b97b,FULL AUTOS
"Reynosa, Tamaulipas",REYNOSA,ezstore85970@ezcorp.com,cesar.moctezuma@ezcorp.com,sandra.mendoza@ezcorp.com,,,85958 - MP - REYNOSA,eJq6hneY4n7m0WYGcN42,pit-ba5f6fad-83a1-47ff-b509-75229ff85e39,PARCIAL
"Tampico, Tamaulipas",TAMPICO,ezstore85969@ezcorp.com,cesar.moctezuma@ezcorp.com,sandra.mendoza@ezcorp.com,,,85969 - MP - Tampico,WCHyow6KpjLFYriQWdbJ,pit-06342f55-c0e0-4fa9-8dfe-af06e3d7786d,PARCIAL
"Tapachula, Chiapas",TAPACHULA,ezstore85963@ezcorp.com,jorge_suriano@ezcorp.com,lorena_segura@ezcorp.com,,,85963- MP - TAPACHULA,ts3oTud0rw1Iat02zBAi,pit-a9fbab2c-9e83-4306-a0d8-c77c7c32c29f,PARCIAL
"Temixco, Morelos",TEMIXCO,ezstore85950@ezcorp.com,saul.ortiz@ezcorp.com,roman_gutierrez@ezcorp.com,,,85950 - MP - Temixco,yjqKxoO02rsdwdJZSPmD,pit-a369d5cd-f832-4a38-b2b9-d3a3316ee76b,PARCIAL
"Texcoco, Estado de México",TEXCOCO,ezstore85953@ezcorp.com,jorge.rodriguez1@ezcorp.com,norma_fajardo@ezcorp.com,,,85953 - MP - Texcoco,vuPH36qujg6dSf92P5p2,pit-0800a342-988c-4841-9295-bfced76e9209,PARCIAL
"Metepec, Estado de México",METEPEC,ezstore85937@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85937 - MP - METEPEC,NSDniGzjxotVDNa5YxqW,pit-aedff844-3aef-40a8-a201-6044a67b8710,PARCIAL
"Toluca, Estado de México",GRAND PLAZA,ezstore85935@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85935 - MP - Pilares,uZnMH5bO6MXTHcgHeyq9,pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5,PARCIAL
"Toluca, Estado de México",ISIDRO FABELA,ezstore85935@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85935 - MP - Pilares,uZnMH5bO6MXTHcgHeyq9,pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5,PARCIAL
"Toluca, Estado de México",INDEPENDENCIA,ezstore85935@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85935 - MP - Pilares,uZnMH5bO6MXTHcgHeyq9,pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5,PARCIAL
"Lerma, Estado de México",SENDERO,ezstore85935@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85935 - MP - Pilares,uZnMH5bO6MXTHcgHeyq9,pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5,PARCIAL
"Tulyehualco, Ciudad de México",TULYEHUALCO,ezstore85930@ezcorp.com,eduardo_valle@ezcorp.com,sara_chavezo@ezcorp.com,,,85930 - MP - TULYEHUALCO,rQYjjwsGnjEGagskOxix,pit-8001d42c-c771-47d3-8ec3-44521f4318d2,PARCIAL
"Tuxtla, Chiapas",TUXTLA 3,ezstore85962@ezcorp.com,antonio_campuzano@ezcorp.com,lorena_segura@ezcorp.com,,,85962 - MP - Tuxtla 3,y9nelIq8hkrfdCQKK72o,pit-07731a1a-a36a-4d4c-8076-8a27c5bde219,PARCIAL
"Uruapan, Michoacán",URUAPAN,ezstore85966@ezcorp.com,clopezt@ezcorp.com,eduardo_vazquez@ezcorp.com,,,85966 - MP - Uruapan,FoQWuksh4wQjPbVVZ8ZQ,pit-66abaa78-3f09-4b87-ac81-e0e4369dfd2e,PARCIAL
"Zacatepec, Morelos",ZACATEPEC,ezstore85949@ezcorp.com,saul.ortiz@ezcorp.com,roman_gutierrez@ezcorp.com,,,85949 - MP - ZACATEPEC,U0S0QntXgSOz9Fx18Db4,pit-20e92122-65b6-40ec-9a24-f2e79884898b,PARCIAL
"Zinacantepec, Estado de México",ZINACANTEPEC,ezstore85942@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85942 - MP - ZINACANTEPEC,MJU2fZ5VxQfHNgEfEQkg,pit-664d8847-9182-43d9-8c05-96a7d08e1733,PARCIAL
"Zitácuaro, Michoacán",ZITACUARO,ezstore85944@ezcorp.com,jonathan_ventura@ezcorp.com,heidi_masse@ezcorp.com,,,85944 - MP - ZITACUARO,VYxTksrNuhmw9yL0Su2V,pit-7104e72f-7b7a-4a0c-be8b-f99c5e55b675,PARCIAL
"Narvarte Oriente, Ciudad de México",EUGENIA,ezstore85974@ezcorp.com,isabel.de@ezcorp.com,hector_ramirez2@ezcorp.com,,,85974 - MP -Eugenia,nF1uEaYB3mCK5em9bPn2,pit-dbdaabeb-b916-4ff7-baff-6c4fe31f656d,FULL AUTOS
"Morelia, Michoacán",MORELIA 3,ezstore85965@ezcorp.com,francisco_hernandez@ezcorp.com,eduardo_vazquez@ezcorp.com,,,85965 - MP - MORELIA 3,rET7fvqI670aRPADfUwj,pit-1036c92c-9f79-4110-bd24-6b19cc600cf9,NO DIGITAL
"Toluca, Estado de México",ISIDRO FABELA,ezstore85940@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85940 - MP - Isidro Fabela,clhDZ0hIllKfV0AcgW53,pit-01ca9688-9bec-4c24-afea-5df41ce88299,NO DIGITAL
"Metepec, Estado de México",METEPEC,ezstore85937@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85937 - MP - METEPEC,NSDniGzjxotVDNa5YxqW,pit-aedff844-3aef-40a8-a201-6044a67b8710,PARCIAL
"Lerma, Estado de México",SENDERO,ezstore85938@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85938 - MP - SENDERO,UsHXqoj2l6ND7Uc7sEo2,pit-b539f2ee-2b33-4e6b-8338-d4d7863758d3,NO DIGITAL
"Toluca, Estado de México",GRAND PLAZA,ezstore85941@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85941 - MP - Grand Plaza,Xqpdy12avIk4NFsOhPBX,pit-aefd0fd7-2f77-4ef1-9b6e-716c07db924c,NO DIGITAL
"Toluca, Estado de México",INDEPENDENCIA,ezstore85939@ezcorp.com,ursula.valle@ezcorp.com,heidi_masse@ezcorp.com,,,85939 - MP - Independencia,RLAs9sQwbW2DOwzrTMYI,pit-402e5801-127b-4ea7-b540-c068ca59a9bc,NO DIGITAL
1 SUCURSAL TIENDA CORREO TIENDA CORREO DM CORREO RDO Agente BUCEFALO Agente BUCEFALO ID SC BUCEFALO ID LOCATION BUCEFALO SC TOKEN BUCEFALO TIPO DE TIENDA
2 - CUENTA PRINCIPAL - - - - - Monte Providencia GbKkBpCmKu2QmloKFHy3 pit-4e4266f8-97ac-4150-a971-cc9158809640 -
3 Atlacomulco, Estado de México ATLACOMULCO ezstore85943@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85943 - MP - ATLACOMULCO XzQ5Wi3RqHwc3AyE8QrI pit-12986b2f-dda2-44f5-8fd5-1902f8bba377 PARCIAL
4 Atizapán, Estado de México ATIZAPAN ezstore85934@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 85934 - MP - Atizapán IE7ci6Ddfk1WvQabEa4q pit-a263eb39-24b7-47d9-aadf-5ca9b997d15d PARCIAL
5 Altamira, Tamaulipas ALTAMIRA ezstore85967@ezcorp.com cesar.moctezuma@ezcorp.com sandra.mendoza@ezcorp.com 85967 - MP - Altamira w1mYacmbTLjVwVDFF5Jx pit-fcd0d3c6-e763-4d21-aac6-c0164ec4ef23 PARCIAL
6 Cancún, Quintana Roo CANCUN ezstore85976@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85976 - MP - Cancún uJEn2iuUficuml9zxAnt pit-7a23a1d8-01a3-486b-81e1-78e4b0f416c1 FULL AUTOS
7 Ciudad del Carmen, Campeche CD CARMEN ezstore85960@ezcorp.com yaneth_perez@ezcorp.com lorena_segura@ezcorp.com 85960 - MP - Cd. Carmen XkduzafvwsrWcEFg6Qlj pit-d9d5e991-54d8-4c58-90ca-7cabf53247e5 PARCIAL
8 Ciudad Satélite, Estado de México SATELITE ezstore85971@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85971 - MP - Satélite R34lUVVpltnB8Z1RqnEB pit-62df3e5c-55d0-4192-8f3a-06272e0eb1bd FULL AUTOS
9 Chilpancingo, Guerrero CHILPANCINGO ezstore85947@ezcorp.com saul.ortiz@ezcorp.com roman_gutierrez@ezcorp.com 85947 - MP - Chilpancingo qe3s72MRDhbEWlaFZ2Ko pit-4fcb095f-504b-4a38-a9ab-f7724ceee00e PARCIAL
10 Cuajimalpa, Ciudad de México CUAJIMALPA ezstore85933@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 85933 - MP - CUAJIMALPA VwDgbGbahFXPSyZmpCzt pit-c1b5442f-81b5-46a1-818d-e7eae32a4066 PARCIAL
11 Cuautla, Morelos CUAUTLA ezstore85945@ezcorp.com saul.ortiz@ezcorp.com roman_gutierrez@ezcorp.com 85945 - MP - Cuautla arEgADUgzqjK3qH7LXSz pit-19d774a4-5ad0-4421-be26-5033c4631a2a PARCIAL
12 Ecatepec, Estado de México ECATEPEC ezstore85952@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 85952 - MP - ECATEPEC jB8BGwt9NoMAd3NGKjai pit-298e19b3-94d4-493d-bc47-d5be3045f5a0 PARCIAL
13 El Salado, Ciudad de México PLAZA EL SALADO ezstore85956@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 85932 - MP - PLAZA EL SALADO WLPVTRxg7W074dfzBxZL pit-ba851b3e-141b-4d90-b4a0-050e83d900a4 PARCIAL
14 Huauchinango, Puebla HUAUCHINANGO ezstore85968@ezcorp.com cesar.moctezuma@ezcorp.com sandra.mendoza@ezcorp.com 85968 - MP - HUAUCHINANGO gtGA3sLqKBNSIAuf5hjx pit-f510cc8c-b6e9-4b92-aa86-06df58099614 PARCIAL
15 Interlomas, Estado de México INTERLOMAS ezstore85977@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85977 - MP - Interlomas 2eJPAdEGjC7iPhDDAeoy pit-a19a7d07-2658-4b5b-b7f6-48ce5b264c9b FULL AUTOS
16 Ixmiquilpan, Hidalgo IXMIQUILPAN ezstore85955@ezcorp.com nancy_godinez2@ezcorp.com miguel_paredes@ezcorp.com 85955 - MP - IXMIQUILPAN ZvVhdvqBU5K9YLEH0fy3 pit-88ff6484-12d7-4f39-b604-c09aa324afac PARCIAL
17 Izcalli, Estado de México IZCALLI ezstore85954@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 85954 - MP - Izcalli r0fiuXv6zQnFyXJW2SWU pit-8e8596a8-5323-4f71-8e55-af2cf0f450d1 PARCIAL
18 Jojutla, Morelos JOJUTLA ezstore85946@ezcorp.com saul.ortiz@ezcorp.com roman_gutierrez@ezcorp.com 85946 - MP - Jojutla 5qebe8IhFUjlcBvJcqCa pit-53f52dc0-6089-4caa-aaee-ed17ae8b631c PARCIAL
19 La Viga, Ciudad de México LA VIGA ezstore58932@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 58932 - MP - La Viga fKn9SaXZoKcjjLryg10v pit-350d963b-952a-49ce-8063-eb28b9826ca1 PARCIAL
20 Marina Nacional, Ciudad de México MARINA NACIONAL ezstore85931@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 85931 - MP - Marina Nacional HvDw9Eg3rjrwkbQJXqfi pit-5f6c7bb9-8a65-41a2-850e-ac79e6f3ad01 PARCIAL
21 Miacatlán, Morelos MIACATLAN ezstore85948@ezcorp.com saul.ortiz@ezcorp.com roman_gutierrez@ezcorp.com 85948 - MP - MIACATLAN RWqOypPx7S6t7MCleu2K pit-a3ce8126-3409-427f-b703-c77741060b22 PARCIAL
22 Miahuatlán, Oaxaca MIAHUATLAN ezstore85959@ezcorp.com jorge_suriano@ezcorp.com lorena_segura@ezcorp.com 85959 - MP - MIAHUATLAN YGOlLwAl2AeN9JtZ47vV pit-119dc508-cfe7-4d69-8c89-a5f504fee1b3 PARCIAL
23 Morelia, Michoacán MORELIA 1 ezstore85964@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85964 - MP - Morelia 1 jE41bVhhnb5T505BFm4F pit-0934169e-ce16-4a4e-a366-36e65edcee87 FULL AUTOS
24 Pinotepa, Oaxaca PINOTEPA ezstore85957@ezcorp.com jorge_suriano@ezcorp.com lorena_segura@ezcorp.com 85957 - MP - PINOTEPA 7H91g95hhLKwIUqSk0Rg pit-f68877be-db7c-4414-afa1-dc434fbba0f0 PARCIAL
25 Playa del Carmen, Quintana Roo VILLAS DEL SOL ezstore85961@ezcorp.com patricia_velazquez2@ezcorp.com lorena_segura@ezcorp.com 85961 - MP - VILLAS DEL SOL nRSeOhlhQ3vyirTKYhPi pit-8e82f2f6-9e13-400d-af02-4ae61765aab3 PARCIAL
26 Pochutla, Oaxaca POCHUTLA ezstore85958@ezcorp.com jorge_suriano@ezcorp.com lorena_segura@ezcorp.com 85958 - MP - POCHUTLA HvyNhH2IOe9ByeZrRo0N pit-0fb64a32-5685-4f55-888b-dba78dedf9cd PARCIAL
27 Puebla, Puebla PUEBLA ezstore85973@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85973 - MP - Puebla KEZ7dAhgwzK4uZfMvZuj pit-7faad038-64a9-4ad7-8a40-9bece2ba4b66 FULL AUTOS
28 Querétaro, Querétaro QUERETARO ezstore85975@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com Agente DEMO QRO rZ5YCSBqZRVhDdcSoQvL 85975 - MP - Querétaro blRZ21GlzgUCA7bl2uVw pit-9ba01577-ccd7-42c6-a908-ba7594f0b97b FULL AUTOS
29 Reynosa, Tamaulipas REYNOSA ezstore85970@ezcorp.com cesar.moctezuma@ezcorp.com sandra.mendoza@ezcorp.com 85958 - MP - REYNOSA eJq6hneY4n7m0WYGcN42 pit-ba5f6fad-83a1-47ff-b509-75229ff85e39 PARCIAL
30 Tampico, Tamaulipas TAMPICO ezstore85969@ezcorp.com cesar.moctezuma@ezcorp.com sandra.mendoza@ezcorp.com 85969 - MP - Tampico WCHyow6KpjLFYriQWdbJ pit-06342f55-c0e0-4fa9-8dfe-af06e3d7786d PARCIAL
31 Tapachula, Chiapas TAPACHULA ezstore85963@ezcorp.com jorge_suriano@ezcorp.com lorena_segura@ezcorp.com 85963- MP - TAPACHULA ts3oTud0rw1Iat02zBAi pit-a9fbab2c-9e83-4306-a0d8-c77c7c32c29f PARCIAL
32 Temixco, Morelos TEMIXCO ezstore85950@ezcorp.com saul.ortiz@ezcorp.com roman_gutierrez@ezcorp.com 85950 - MP - Temixco yjqKxoO02rsdwdJZSPmD pit-a369d5cd-f832-4a38-b2b9-d3a3316ee76b PARCIAL
33 Texcoco, Estado de México TEXCOCO ezstore85953@ezcorp.com jorge.rodriguez1@ezcorp.com norma_fajardo@ezcorp.com 85953 - MP - Texcoco vuPH36qujg6dSf92P5p2 pit-0800a342-988c-4841-9295-bfced76e9209 PARCIAL
34 Metepec, Estado de México METEPEC ezstore85937@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85937 - MP - METEPEC NSDniGzjxotVDNa5YxqW pit-aedff844-3aef-40a8-a201-6044a67b8710 PARCIAL
35 Toluca, Estado de México GRAND PLAZA ezstore85935@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85935 - MP - Pilares uZnMH5bO6MXTHcgHeyq9 pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5 PARCIAL
36 Toluca, Estado de México ISIDRO FABELA ezstore85935@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85935 - MP - Pilares uZnMH5bO6MXTHcgHeyq9 pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5 PARCIAL
37 Toluca, Estado de México INDEPENDENCIA ezstore85935@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85935 - MP - Pilares uZnMH5bO6MXTHcgHeyq9 pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5 PARCIAL
38 Lerma, Estado de México SENDERO ezstore85935@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85935 - MP - Pilares uZnMH5bO6MXTHcgHeyq9 pit-dd42c1ce-2ab7-4bf9-8bc0-c0087a83b2e5 PARCIAL
39 Tulyehualco, Ciudad de México TULYEHUALCO ezstore85930@ezcorp.com eduardo_valle@ezcorp.com sara_chavezo@ezcorp.com 85930 - MP - TULYEHUALCO rQYjjwsGnjEGagskOxix pit-8001d42c-c771-47d3-8ec3-44521f4318d2 PARCIAL
40 Tuxtla, Chiapas TUXTLA 3 ezstore85962@ezcorp.com antonio_campuzano@ezcorp.com lorena_segura@ezcorp.com 85962 - MP - Tuxtla 3 y9nelIq8hkrfdCQKK72o pit-07731a1a-a36a-4d4c-8076-8a27c5bde219 PARCIAL
41 Uruapan, Michoacán URUAPAN ezstore85966@ezcorp.com clopezt@ezcorp.com eduardo_vazquez@ezcorp.com 85966 - MP - Uruapan FoQWuksh4wQjPbVVZ8ZQ pit-66abaa78-3f09-4b87-ac81-e0e4369dfd2e PARCIAL
42 Zacatepec, Morelos ZACATEPEC ezstore85949@ezcorp.com saul.ortiz@ezcorp.com roman_gutierrez@ezcorp.com 85949 - MP - ZACATEPEC U0S0QntXgSOz9Fx18Db4 pit-20e92122-65b6-40ec-9a24-f2e79884898b PARCIAL
43 Zinacantepec, Estado de México ZINACANTEPEC ezstore85942@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85942 - MP - ZINACANTEPEC MJU2fZ5VxQfHNgEfEQkg pit-664d8847-9182-43d9-8c05-96a7d08e1733 PARCIAL
44 Zitácuaro, Michoacán ZITACUARO ezstore85944@ezcorp.com jonathan_ventura@ezcorp.com heidi_masse@ezcorp.com 85944 - MP - ZITACUARO VYxTksrNuhmw9yL0Su2V pit-7104e72f-7b7a-4a0c-be8b-f99c5e55b675 PARCIAL
45 Narvarte Oriente, Ciudad de México EUGENIA ezstore85974@ezcorp.com isabel.de@ezcorp.com hector_ramirez2@ezcorp.com 85974 - MP -Eugenia nF1uEaYB3mCK5em9bPn2 pit-dbdaabeb-b916-4ff7-baff-6c4fe31f656d FULL AUTOS
46 Morelia, Michoacán MORELIA 3 ezstore85965@ezcorp.com francisco_hernandez@ezcorp.com eduardo_vazquez@ezcorp.com 85965 - MP - MORELIA 3 rET7fvqI670aRPADfUwj pit-1036c92c-9f79-4110-bd24-6b19cc600cf9 NO DIGITAL
47 Toluca, Estado de México ISIDRO FABELA ezstore85940@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85940 - MP - Isidro Fabela clhDZ0hIllKfV0AcgW53 pit-01ca9688-9bec-4c24-afea-5df41ce88299 NO DIGITAL
48 Metepec, Estado de México METEPEC ezstore85937@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85937 - MP - METEPEC NSDniGzjxotVDNa5YxqW pit-aedff844-3aef-40a8-a201-6044a67b8710 PARCIAL
49 Lerma, Estado de México SENDERO ezstore85938@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85938 - MP - SENDERO UsHXqoj2l6ND7Uc7sEo2 pit-b539f2ee-2b33-4e6b-8338-d4d7863758d3 NO DIGITAL
50 Toluca, Estado de México GRAND PLAZA ezstore85941@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85941 - MP - Grand Plaza Xqpdy12avIk4NFsOhPBX pit-aefd0fd7-2f77-4ef1-9b6e-716c07db924c NO DIGITAL
51 Toluca, Estado de México INDEPENDENCIA ezstore85939@ezcorp.com ursula.valle@ezcorp.com heidi_masse@ezcorp.com 85939 - MP - Independencia RLAs9sQwbW2DOwzrTMYI pit-402e5801-127b-4ea7-b540-c068ca59a9bc NO DIGITAL
+20
View File
@@ -0,0 +1,20 @@
NODOS SET (Consolidación de datos API):
--------------------------------------------------------------------------------
Datos API Cuenta objetivo - SUCURSAL:
Type: n8n-nodes-base.set
(empty values dict)
Datos API Cuenta Origen:
Type: n8n-nodes-base.set
(empty values dict)
CONEXIONES A NODOS DE BÚSQUEDA:
--------------------------------------------------------------------------------
Traceback (most recent call last):
File "<stdin>", line 47, in <module>
File "C:\Users\Uriel Jareth\AppData\Local\Programs\Python\Python312\Lib\encodings\cp1252.py", line 19, in encode
return codecs.charmap_encode(input,self.errors,encoding_table)[0]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeEncodeError: 'charmap' codec can't encode character '\u2192' in position 27: character maps to <undefined>
+46
View File
@@ -0,0 +1,46 @@
Nombre del cliente potencial,Nombre del contacto,teléfono,correo electrónico,secuencia,fase,Valor del cliente potencial,fuente,asignado,Creado el,Actualizado el,ID de razón de abandono,nombre de la razón de abandono,Seguidores,Notas,etiquetas,Puntuación de compromiso,estado,Fecha prevista de cierre,Probabilidad de pronóstico,Recuento de deslizamientos previstos,Retraso del Pronóstico (Días),Fuente de Posible Cliente,Fuente de Prospecto,Sucursal,Lead Descartado,Visita a Sucursal,Fecha de ultima visita a sucursal,"Persona que atendió al prospecto ",Vehículo,"Modalidad de Empeño ",Canal de Origen,TIENDA,ID de oportunidad,ID de contacto,ID del paso de la secuencia,ID de la secuencia,"Días desde el último paso Cambiar fecha ","Días desde la Fecha del último Cambiar de Estado ","Días desde Actualizado el "
Adan Blancas Camacho,AMANDA LOPEZ,+525548770788,ihernandez@grupogrmx.net,Standar,PROSPECTO NUEVO,15000,GALLARDETES,,2026-04-28T16:14:25.275Z,2026-05-12T13:19:19.520Z,,,,,formulario,0,open,,,,,WhatsApp,PROSPECCIÓN,MORELIA,,,,EVERARDO,BMW 118IA SPORT 2020,GPS,FORMULARIO,,0MPJYamLqeHTzakviOuJ,mX2r6HveftijRBG7RwMQ,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"23 Días ","23 Días ","9 Días "
Patricia Parra NAVARRO,PATRICIA PARRA NAVARRO,+524431326208,alan_carmona@hotmail.com,Standar,Contactado,50000,Formulario,,2026-04-14T23:11:00.339Z,2026-04-27T16:41:31.515Z,,,,,"formulario,contacto existente",0,open,,,,,,LEAD DIGITAL,MORELIA,,,,EVERARDO,VW SAVEIRO ROBUST 2020,GPS,FORMULARIO,,7A6DRXbCahg9LAswW8OE,30UjqrQ6eQvEeUaenJj1,b46078fb-4cc8-49ac-af0d-21934566cb0b,riHamqZZxJEp1hy1QJTg,"27 Días ","37 Días ","24 Días "
Victor Manuel Serrano Guzmán,Victor Manuel Serrano Guzmán,+524438446682,mg7919662@gmail.com,Standar,Intento de contacto,0,Formulario,,2026-04-24T12:38:15.375Z,2026-05-12T13:20:16.176Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Vento Lithium 2019,Sin Dejarlo (GPS),FACEBOOK,,0ZR7OeT76Lwo3KrL0DwK,NHWmpkLUgPR9eonniQtN,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"21 Días ","27 Días ","9 Días "
Victor Eduardo Fraga Barajas,Victor Eduardo Fraga barajas,+524434848443,edubara17@gmail.com,Standar,Intento de contacto,0,Formulario,,2026-04-24T12:38:15.064Z,2026-05-12T13:20:32.449Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Italika Moto de trabajo 2025,Tradicional (Resguardo),FACEBOOK,,2FJ2IRzOrnWC5IdFd87e,sVGkjofdVEs5Gx6T2kKp,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"21 Días ","27 Días ","9 Días "
Matzayani Avalos,Matzayani Avalos,+524431575435,matzadebaldo@gmail.com,Standar,PROSPECTO NUEVO,0,Facebook,,2026-04-24T12:38:14.179Z,2026-05-12T13:19:09.623Z,,,,,facebook-ads,0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,Mazda Cx3 tourning 2018,Sin dejarlo (GPS),FACEBOOK,,3Mezta7oufcEr5qd2Mxh,QSO8xTKVBDATeHuivQ9T,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
Violeta Vazquez,Violeta Vazquez,+524431863340,violetamx65@gmail.com,Standar,PROSPECTO NUEVO,0,Formulario,,2026-04-24T12:38:14.900Z,2026-04-27T16:41:41.380Z,,,,,"formulario,contacto existente",0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,Sentra Lujo 2015,Sin Dejarlo (GPS),FORMULARIO,,65oB4FuI3x2DuXuLDo03,89So4QqMKrpLNOl6qphw,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
Jose Luis Bañales Pérez,Jose Luis Bañales Pérez,+523319968990,joseluisbanalesperez@gmail.com,Standar,Intento de contacto,7000,Formulario,,2026-04-24T12:38:15.240Z,2026-05-12T13:19:20.807Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Italika Motocicleta ft 200 2025,Sin Dejarlo (GPS),FACEBOOK,,7V61eTeth744bRbTyOqU,l62OVbqEUQH3eQyo7Rf1,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"21 Días ","27 Días ","9 Días "
Juan Carlos Serrano,Juan carlos Serrano,+524451091314,jcarloss50@gmail.com,Standar,Cumple requisitos,25000,LEAD DIGITAL,,2026-05-07T13:01:57.888Z,2026-05-20T22:02:09.703Z,,,,,sucursal,0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,Hyundai 2017 Creta,Sin Dejarlo (GPS),FORMULARIO,,APhmiuCnlxdSDuorBUR9,tRPoL6Zy28AItdJkDQOp,b7c26dfc-afe5-4a37-87f9-d041c84bd361,riHamqZZxJEp1hy1QJTg,"14 Días ","14 Días ","1 Día "
Angelica Maria Garcia Sereno,Angelica Maria Garcia Sereno,+524591150366,anguimariagarcia@hotmail.com,Standar,PROSPECTO NUEVO,0,Facebook,,2026-04-24T12:38:13.346Z,2026-05-12T13:19:53.467Z,,,,,facebook-ads,0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,fiat like 2019,Sin dejarlo (GPS),FACEBOOK,,BJbIoLxuG9vBtYDH7TdW,grhZxGDN2Em9VLqlIeUq,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
GERARDO PADILLA,GERARDO PADILLA,+526632761925,,Standar,Cumple requisitos,0,PROSPECCIÓN,,2026-05-20T19:59:29.806Z,2026-05-20T19:59:41.644Z,,,,,sucursal,0,open,,,,,,SUCURSAL,"Morelia, Michoacán",,,,,BMW 2019 SERIE 5 520 IA SPORT,Tradicional (Resguardo),SUCURSAL,,Bj2bINlklDAoLmTsyg3r,Gw1tRtpW2SIvhxsqp76J,b7c26dfc-afe5-4a37-87f9-d041c84bd361,riHamqZZxJEp1hy1QJTg,"1 Día ","1 Día ","1 Día "
Brayan Ayala,Brayan Ayala,+522812021575,brayanayaladiaz@hotmail.com,Standar,PROSPECTO NUEVO,0,Facebook,,2026-04-24T12:38:14.792Z,2026-04-27T16:41:53.258Z,,,,"n8n
n8n
n8n
",formulario,0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,kia Rio L Sedan 2019,Sin dejarlo (GPS),FORMULARIO,,KzahSAnsKayStvikTC09,UfF811CbgJ5WmU06FCgJ,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
Carlota Estefani Cabrera Castro,Carlota Estefani Cabrera Castro,+524439636002,cabreraestefani082@gmail.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:38:13.961Z,2026-05-12T13:20:22.389Z,,,,"Note de ejemplo
Note de ejemplo
Note de ejemplo
","contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,MG GT Alpha 2024,Sin Dejarlo (GPS),FACEBOOK,,LiMidyaSJPXUDOBvcrCp,4zo9wjGytjk89LzB8zeH,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
JORGE TAFOLLA,JORGE TAFOLLA,+523319040567,,Standar,Cumple requisitos,250000,PROSPECCIÓN,,2026-05-20T19:57:28.807Z,2026-05-20T21:59:31.607Z,,,,,sucursal,0,open,,,,,,PROSPECCIÓN,"Morelia, Michoacán",,,,,FORD 2018 F-150 XL CREW CAB 4X4,Tradicional (Resguardo),SUCURSAL,MORELIA 1,OGjl6PDAnWKTF9zVvLjt,xRHocdmW9KhFp7fOgyVs,b7c26dfc-afe5-4a37-87f9-d041c84bd361,riHamqZZxJEp1hy1QJTg,"1 Día ","1 Día ","1 Día "
GUADALUPE SILVA,GUADALUPE SILVA,+524433682836,,Standar,PROSPECTO NUEVO,0,,,2026-04-24T12:38:13.206Z,2026-04-27T16:41:32.985Z,,,,,sucursal,0,open,,,,,,SUCURSAL,"Morelia, Michoacán",,,,,,,SUCURSAL,,RjLOjf6Wf5LAbjCstUk5,uzX3q4N8gGMJR2Eljpc8,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
GUADALUPE SILVA,JUAN CLAUDIO PEREZ,+524431862772,,Standar,Contactado,0,,,2026-04-24T12:38:15.205Z,2026-04-27T16:41:33.112Z,,,,,sucursal,0,open,,,,,Llamada,Lead digital,"Morelia, Michoacán",,,,,,,SUCURSAL,,RlYE9YM98VXVB2NTvJd9,621qSRcgxTYzvzzUvMGG,b46078fb-4cc8-49ac-af0d-21934566cb0b,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
ALEJANDRO GARCIA MORENO,ALEJANDRO GARCIA MORENO,+524641656944,garcia.or@outlook.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:32:01.722Z,2026-04-27T16:41:41.185Z,,,,,"formulario,contacto existente",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,volkswagen Highline 2019,Sin Dejarlo (GPS),FORMULARIO,,Vhc6jDQn9IQJTKhHiIGE,3Cdh8nsP8CjVk2008Sat,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
Jonathan Alvarez,Jonathan Alvarez,+525659278944,alvarez.itm.ing@gmail.com,Standar,En Pausa,15000,Formulario,,2026-04-24T12:38:13.949Z,2026-05-12T13:19:07.344Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,BMW Mini cooper 2016,Sin Dejarlo (GPS),FACEBOOK,,arWyWbgEbTeFxTPZr0aD,PWLJ0Yqaf2avzuTmTgA4,ef60ca17-a0c1-4aec-8fa5-45b77ca79d08,riHamqZZxJEp1hy1QJTg,"21 Días ","27 Días ","9 Días "
Ivan Bernal Zacarías,Ivan Bernal Zacarías,+528148135940,bernalivan933@gmail.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:38:15.775Z,2026-05-12T13:20:21.585Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Chevrolet Sedan 2016,Sin Dejarlo (GPS),FACEBOOK,,gCtiJ0ignXTXkd35r2DJ,4X0YCdZgXupHFE96jEQ7,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
Joselin Belen Plancarte Cantú,Joselin Belen Plancarte Cantú,+524432436494,jozelyn.plancarte@gmail.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:38:13.949Z,2026-05-12T13:19:50.817Z,,,,,facebook-ads,0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Volkswagen Jetta glo 2015,Sin Dejarlo (GPS),FACEBOOK,,jCXGc9k2CN6Vzudc6Uhh,NZYkO9IkklKiWbUxhRsb,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"21 Días ","27 Días ","9 Días "
Mariel Subía,Mariel Subía,+526331258369,jorlet60@gmail.com,Standar,En Pausa,15000,LEAD DIGITAL,,2026-05-04T01:34:16.917Z,2026-05-20T19:53:14.916Z,,,,,formulario,0,open,,,,,Formulario,Lead Digital,"Morelia, Michoacán",,,,,"Hyundai Motor4, 3.3 2015",Sin Dejarlo (GPS),FORMULARIO,,kYQddd9oMyUcoqDVRz9m,J7JMz3BiUW6hRnIF7kBC,ef60ca17-a0c1-4aec-8fa5-45b77ca79d08,riHamqZZxJEp1hy1QJTg,"1 Día ","18 Días ","1 Día "
Francisco Ramirz,Francisco Ramirz,+524439159059,yepezcnci@gmail.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:38:15.792Z,2026-05-12T13:19:20.716Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,," Bajaj Pulsar ns400z 2025",Tradicional (Resguardo),FACEBOOK,,kfLZ3fxUxnEQZzAQviqs,d1txW5Z0t94PPIgDiwMf,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
Luis Javier Portugal Rivera,Luis Javier Portugal rivera,+524525268692,pekmex90@gmail.com,Standar,PROSPECTO NUEVO,0,Formulario,,2026-04-24T12:38:13.497Z,2026-04-27T16:41:51.973Z,,,,,"formulario,contacto existente",0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,Chevrolet Lsp 5 sedan 2018,Sin Dejarlo (GPS),FORMULARIO,,pAQRd8QByN87RJrHkKMa,wfk69RxKMDIycjXrSzYG,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
Jovani García Sanchez,Jovani García Sanchez,+524433969149,crgs380@gmail.com,Standar,Intento de contacto,50000,Formulario,,2026-04-24T12:32:01.643Z,2026-05-12T13:20:21.604Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Jetta mk6 Sportline tritonic 2.5 2017,Sin Dejarlo (GPS),FACEBOOK,,pNRn7dON1nCRwCc3Gb18,2oUi4KOg2rVOBWA2r5qi,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
MOISES LOPEZ FONSECA,MOISES lOPEZ fONSECA,+524439441314,santiabril554@gmail.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:38:13.961Z,2026-05-12T13:19:20.804Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,CHEVROLET AVEO LS 2020,Sin Dejarlo (GPS),FACEBOOK,,pbOlJVdOvbveabOMC8zS,E8t1MJHCvUdpuShL2IHu,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
Francisco García,Francisco García,+524432274069,fgarciad10@gmail.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:38:15.030Z,2026-05-12T13:19:20.623Z,,,,,facebook-ads,0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Mazda I sport aut 2019,Sin Dejarlo (GPS),FACEBOOK,,plzZzYCsrolJO8R8bp2L,riEFTDpS3Ao6ZCu1wLbe,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
FERNANDO DOMINGUEZ OROZCO,FERNANDO DOMINGUEZ OROZCO,+524434822385,,Standar,PROSPECTO NUEVO,0,,,2026-04-27T08:39:42.568Z,2026-05-12T13:19:01.249Z,,,,,sucursal,0,open,,,,,,,"Morelia, Michoacán",,,,,,,SUCURSAL,,sPZZD3kVohiM5VNq101r,NnpJAUHzhumbOiHBaQcl,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"24 Días ","24 Días ","9 Días "
José Alfredo Villaseñor Arellano,José Alfredo Villaseñor Arellano,+524436844722,villasenorjosealfredo9@gmail.com,Standar,PROSPECTO NUEVO,0,Formulario,,2026-04-24T12:38:14.241Z,2026-04-27T16:41:51.021Z,,,,,"formulario,contacto existente",0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,Yamaha 150 fz16 2011,Sin Dejarlo (GPS),FORMULARIO,,sr5rylGnLgw2w82pzQy9,i3D8lQqAu2MUK9IhW9Im,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
Nora Hinojo,Nora Hinojo,+524491889754,odisea315@hotmail.com,Standar,PROSPECTO NUEVO,0,Formulario,,2026-04-24T12:32:01.648Z,2026-04-27T16:41:51.739Z,,,,,"formulario,contacto existente",0,open,,,,,,LEAD DIGITAL,"Morelia, Michoacán",,,,,Mazda Mazda 3 Hatchback 2018,Sin Dejarlo (GPS),FORMULARIO,,tatkiV6M5yNMle7CdrJx,0zqinFdhlEuJRHPqYia9,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","24 Días "
Eduardo Sistos Gonzalez,Eduardo Sistos Gonzalez,+527531106596,costadeoromich@yahoo.com.mx,Standar,Intento de contacto,52000,Formulario,,2026-04-24T12:38:15.212Z,2026-05-12T13:19:17.559Z,,,,"12
12
","facebook-ads,follow-up",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,,Sin dejarlo (GPS),FACEBOOK,,vqRZC0dWlJkYojw4MylX,XtBTfPfMGRKsKKP7p8jU,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"27 Días ","27 Días ","9 Días "
Luis Antonio Ruiz Gil,Luis Antonio Ruiz Gil,+524435045807,ruizgilluisantonio@gmail.com,Standar,Intento de contacto,56671,Formulario,,2026-04-24T12:38:13.932Z,2026-05-12T13:19:52.970Z,,,,,"contacto existente,facebook-ads",0,open,,,,,,Lead digital,"Morelia, Michoacán",,,,,Honda Odysey 2015,Tradicional (Resguardo),FACEBOOK,,wCtebdNQhQKIHEs6llVF,EFjRaExGbcNtdPrbeeL9,6963723a-54f5-4331-aa82-6ffd1dc7f573,riHamqZZxJEp1hy1QJTg,"21 Días ","27 Días ","9 Días "
Ariel Undefined,ariel,+524251282212,,Standar,PROSPECTO NUEVO,0,,,2026-04-27T15:48:13.515Z,2026-04-27T23:34:41.156Z,,,,,sucursal,0,open,,,,,,,"Morelia, Michoacán.",,,,,Nissan tida advance 2010,Tradicional (Resguardo),SUCURSAL,,7XQvJnHHTrMjNblcRDbe,n7iiQlLNyKB0JPCZdLY9,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"24 Días ","24 Días ","24 Días "
Roberto Chayanne,roberto chayanne,+524434184809,,Standar,PROSPECTO NUEVO,0,,,2026-04-27T15:48:10.682Z,2026-04-27T23:34:41.230Z,,,,,sucursal,0,open,,,,,,Redes Sociales,"Morelia, Michoacán.",,,,,NISSAN versa 2012,Sin Dejarlo (GPS),SUCURSAL,,nIfpyRV3WfCdCg1uQweF,M6a1HmgiJ6Rg7ewN715C,3948819b-c61e-4f46-8dca-3a45a656e070,riHamqZZxJEp1hy1QJTg,"24 Días ","24 Días ","24 Días "
1 Nombre del cliente potencial Nombre del contacto teléfono correo electrónico secuencia fase Valor del cliente potencial fuente asignado Creado el Actualizado el ID de razón de abandono nombre de la razón de abandono Seguidores Notas etiquetas Puntuación de compromiso estado Fecha prevista de cierre Probabilidad de pronóstico Recuento de deslizamientos previstos Retraso del Pronóstico (Días) Fuente de Posible Cliente Fuente de Prospecto Sucursal Lead Descartado Visita a Sucursal Fecha de ultima visita a sucursal Persona que atendió al prospecto Vehículo Modalidad de Empeño Canal de Origen TIENDA ID de oportunidad ID de contacto ID del paso de la secuencia ID de la secuencia Días desde el último paso Cambiar fecha Días desde la Fecha del último Cambiar de Estado Días desde Actualizado el
2 Adan Blancas Camacho AMANDA LOPEZ +525548770788 ihernandez@grupogrmx.net Standar PROSPECTO NUEVO 15000 GALLARDETES 2026-04-28T16:14:25.275Z 2026-05-12T13:19:19.520Z formulario 0 open WhatsApp PROSPECCIÓN MORELIA EVERARDO BMW 118IA SPORT 2020 GPS FORMULARIO 0MPJYamLqeHTzakviOuJ mX2r6HveftijRBG7RwMQ 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 23 Días 23 Días 9 Días
3 Patricia Parra NAVARRO PATRICIA PARRA NAVARRO +524431326208 alan_carmona@hotmail.com Standar Contactado 50000 Formulario 2026-04-14T23:11:00.339Z 2026-04-27T16:41:31.515Z formulario,contacto existente 0 open LEAD DIGITAL MORELIA EVERARDO VW SAVEIRO ROBUST 2020 GPS FORMULARIO 7A6DRXbCahg9LAswW8OE 30UjqrQ6eQvEeUaenJj1 b46078fb-4cc8-49ac-af0d-21934566cb0b riHamqZZxJEp1hy1QJTg 27 Días 37 Días 24 Días
4 Victor Manuel Serrano Guzmán Victor Manuel Serrano Guzmán +524438446682 mg7919662@gmail.com Standar Intento de contacto 0 Formulario 2026-04-24T12:38:15.375Z 2026-05-12T13:20:16.176Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán Vento Lithium 2019 Sin Dejarlo (GPS) FACEBOOK 0ZR7OeT76Lwo3KrL0DwK NHWmpkLUgPR9eonniQtN 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 21 Días 27 Días 9 Días
5 Victor Eduardo Fraga Barajas Victor Eduardo Fraga barajas +524434848443 edubara17@gmail.com Standar Intento de contacto 0 Formulario 2026-04-24T12:38:15.064Z 2026-05-12T13:20:32.449Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán Italika Moto de trabajo 2025 Tradicional (Resguardo) FACEBOOK 2FJ2IRzOrnWC5IdFd87e sVGkjofdVEs5Gx6T2kKp 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 21 Días 27 Días 9 Días
6 Matzayani Avalos Matzayani Avalos +524431575435 matzadebaldo@gmail.com Standar PROSPECTO NUEVO 0 Facebook 2026-04-24T12:38:14.179Z 2026-05-12T13:19:09.623Z facebook-ads 0 open LEAD DIGITAL Morelia, Michoacán Mazda Cx3 tourning 2018 Sin dejarlo (GPS) FACEBOOK 3Mezta7oufcEr5qd2Mxh QSO8xTKVBDATeHuivQ9T 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
7 Violeta Vazquez Violeta Vazquez +524431863340 violetamx65@gmail.com Standar PROSPECTO NUEVO 0 Formulario 2026-04-24T12:38:14.900Z 2026-04-27T16:41:41.380Z formulario,contacto existente 0 open LEAD DIGITAL Morelia, Michoacán Sentra Lujo 2015 Sin Dejarlo (GPS) FORMULARIO 65oB4FuI3x2DuXuLDo03 89So4QqMKrpLNOl6qphw 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
8 Jose Luis Bañales Pérez Jose Luis Bañales Pérez +523319968990 joseluisbanalesperez@gmail.com Standar Intento de contacto 7000 Formulario 2026-04-24T12:38:15.240Z 2026-05-12T13:19:20.807Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán Italika Motocicleta ft 200 2025 Sin Dejarlo (GPS) FACEBOOK 7V61eTeth744bRbTyOqU l62OVbqEUQH3eQyo7Rf1 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 21 Días 27 Días 9 Días
9 Juan Carlos Serrano Juan carlos Serrano +524451091314 jcarloss50@gmail.com Standar Cumple requisitos 25000 LEAD DIGITAL 2026-05-07T13:01:57.888Z 2026-05-20T22:02:09.703Z sucursal 0 open LEAD DIGITAL Morelia, Michoacán Hyundai 2017 Creta Sin Dejarlo (GPS) FORMULARIO APhmiuCnlxdSDuorBUR9 tRPoL6Zy28AItdJkDQOp b7c26dfc-afe5-4a37-87f9-d041c84bd361 riHamqZZxJEp1hy1QJTg 14 Días 14 Días 1 Día
10 Angelica Maria Garcia Sereno Angelica Maria Garcia Sereno +524591150366 anguimariagarcia@hotmail.com Standar PROSPECTO NUEVO 0 Facebook 2026-04-24T12:38:13.346Z 2026-05-12T13:19:53.467Z facebook-ads 0 open LEAD DIGITAL Morelia, Michoacán fiat like 2019 Sin dejarlo (GPS) FACEBOOK BJbIoLxuG9vBtYDH7TdW grhZxGDN2Em9VLqlIeUq 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
11 GERARDO PADILLA GERARDO PADILLA +526632761925 Standar Cumple requisitos 0 PROSPECCIÓN 2026-05-20T19:59:29.806Z 2026-05-20T19:59:41.644Z sucursal 0 open SUCURSAL Morelia, Michoacán BMW 2019 SERIE 5 520 IA SPORT Tradicional (Resguardo) SUCURSAL Bj2bINlklDAoLmTsyg3r Gw1tRtpW2SIvhxsqp76J b7c26dfc-afe5-4a37-87f9-d041c84bd361 riHamqZZxJEp1hy1QJTg 1 Día 1 Día 1 Día
12 Brayan Ayala Brayan Ayala +522812021575 brayanayaladiaz@hotmail.com Standar PROSPECTO NUEVO 0 Facebook 2026-04-24T12:38:14.792Z 2026-04-27T16:41:53.258Z n8n n8n n8n formulario 0 open LEAD DIGITAL Morelia, Michoacán kia Rio L Sedan 2019 Sin dejarlo (GPS) FORMULARIO KzahSAnsKayStvikTC09 UfF811CbgJ5WmU06FCgJ 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
13 Carlota Estefani Cabrera Castro Carlota Estefani Cabrera Castro +524439636002 cabreraestefani082@gmail.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:38:13.961Z 2026-05-12T13:20:22.389Z Note de ejemplo Note de ejemplo Note de ejemplo contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán MG GT Alpha 2024 Sin Dejarlo (GPS) FACEBOOK LiMidyaSJPXUDOBvcrCp 4zo9wjGytjk89LzB8zeH 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
14 JORGE TAFOLLA JORGE TAFOLLA +523319040567 Standar Cumple requisitos 250000 PROSPECCIÓN 2026-05-20T19:57:28.807Z 2026-05-20T21:59:31.607Z sucursal 0 open PROSPECCIÓN Morelia, Michoacán FORD 2018 F-150 XL CREW CAB 4X4 Tradicional (Resguardo) SUCURSAL MORELIA 1 OGjl6PDAnWKTF9zVvLjt xRHocdmW9KhFp7fOgyVs b7c26dfc-afe5-4a37-87f9-d041c84bd361 riHamqZZxJEp1hy1QJTg 1 Día 1 Día 1 Día
15 GUADALUPE SILVA GUADALUPE SILVA +524433682836 Standar PROSPECTO NUEVO 0 2026-04-24T12:38:13.206Z 2026-04-27T16:41:32.985Z sucursal 0 open SUCURSAL Morelia, Michoacán SUCURSAL RjLOjf6Wf5LAbjCstUk5 uzX3q4N8gGMJR2Eljpc8 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
16 GUADALUPE SILVA JUAN CLAUDIO PEREZ +524431862772 Standar Contactado 0 2026-04-24T12:38:15.205Z 2026-04-27T16:41:33.112Z sucursal 0 open Llamada Lead digital Morelia, Michoacán SUCURSAL RlYE9YM98VXVB2NTvJd9 621qSRcgxTYzvzzUvMGG b46078fb-4cc8-49ac-af0d-21934566cb0b riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
17 ALEJANDRO GARCIA MORENO ALEJANDRO GARCIA MORENO +524641656944 garcia.or@outlook.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:32:01.722Z 2026-04-27T16:41:41.185Z formulario,contacto existente 0 open Lead digital Morelia, Michoacán volkswagen Highline 2019 Sin Dejarlo (GPS) FORMULARIO Vhc6jDQn9IQJTKhHiIGE 3Cdh8nsP8CjVk2008Sat 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
18 Jonathan Alvarez Jonathan Alvarez +525659278944 alvarez.itm.ing@gmail.com Standar En Pausa 15000 Formulario 2026-04-24T12:38:13.949Z 2026-05-12T13:19:07.344Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán BMW Mini cooper 2016 Sin Dejarlo (GPS) FACEBOOK arWyWbgEbTeFxTPZr0aD PWLJ0Yqaf2avzuTmTgA4 ef60ca17-a0c1-4aec-8fa5-45b77ca79d08 riHamqZZxJEp1hy1QJTg 21 Días 27 Días 9 Días
19 Ivan Bernal Zacarías Ivan Bernal Zacarías +528148135940 bernalivan933@gmail.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:38:15.775Z 2026-05-12T13:20:21.585Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán Chevrolet Sedan 2016 Sin Dejarlo (GPS) FACEBOOK gCtiJ0ignXTXkd35r2DJ 4X0YCdZgXupHFE96jEQ7 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
20 Joselin Belen Plancarte Cantú Joselin Belen Plancarte Cantú +524432436494 jozelyn.plancarte@gmail.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:38:13.949Z 2026-05-12T13:19:50.817Z facebook-ads 0 open Lead digital Morelia, Michoacán Volkswagen Jetta glo 2015 Sin Dejarlo (GPS) FACEBOOK jCXGc9k2CN6Vzudc6Uhh NZYkO9IkklKiWbUxhRsb 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 21 Días 27 Días 9 Días
21 Mariel Subía Mariel Subía +526331258369 jorlet60@gmail.com Standar En Pausa 15000 LEAD DIGITAL 2026-05-04T01:34:16.917Z 2026-05-20T19:53:14.916Z formulario 0 open Formulario Lead Digital Morelia, Michoacán Hyundai Motor4, 3.3 2015 Sin Dejarlo (GPS) FORMULARIO kYQddd9oMyUcoqDVRz9m J7JMz3BiUW6hRnIF7kBC ef60ca17-a0c1-4aec-8fa5-45b77ca79d08 riHamqZZxJEp1hy1QJTg 1 Día 18 Días 1 Día
22 Francisco Ramirz Francisco Ramirz +524439159059 yepezcnci@gmail.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:38:15.792Z 2026-05-12T13:19:20.716Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán Bajaj Pulsar ns400z 2025 Tradicional (Resguardo) FACEBOOK kfLZ3fxUxnEQZzAQviqs d1txW5Z0t94PPIgDiwMf 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
23 Luis Javier Portugal Rivera Luis Javier Portugal rivera +524525268692 pekmex90@gmail.com Standar PROSPECTO NUEVO 0 Formulario 2026-04-24T12:38:13.497Z 2026-04-27T16:41:51.973Z formulario,contacto existente 0 open LEAD DIGITAL Morelia, Michoacán Chevrolet Lsp 5 sedan 2018 Sin Dejarlo (GPS) FORMULARIO pAQRd8QByN87RJrHkKMa wfk69RxKMDIycjXrSzYG 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
24 Jovani García Sanchez Jovani García Sanchez +524433969149 crgs380@gmail.com Standar Intento de contacto 50000 Formulario 2026-04-24T12:32:01.643Z 2026-05-12T13:20:21.604Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán Jetta mk6 Sportline tritonic 2.5 2017 Sin Dejarlo (GPS) FACEBOOK pNRn7dON1nCRwCc3Gb18 2oUi4KOg2rVOBWA2r5qi 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
25 MOISES LOPEZ FONSECA MOISES lOPEZ fONSECA +524439441314 santiabril554@gmail.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:38:13.961Z 2026-05-12T13:19:20.804Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán CHEVROLET AVEO LS 2020 Sin Dejarlo (GPS) FACEBOOK pbOlJVdOvbveabOMC8zS E8t1MJHCvUdpuShL2IHu 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
26 Francisco García Francisco García +524432274069 fgarciad10@gmail.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:38:15.030Z 2026-05-12T13:19:20.623Z facebook-ads 0 open Lead digital Morelia, Michoacán Mazda I sport aut 2019 Sin Dejarlo (GPS) FACEBOOK plzZzYCsrolJO8R8bp2L riEFTDpS3Ao6ZCu1wLbe 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
27 FERNANDO DOMINGUEZ OROZCO FERNANDO DOMINGUEZ OROZCO +524434822385 Standar PROSPECTO NUEVO 0 2026-04-27T08:39:42.568Z 2026-05-12T13:19:01.249Z sucursal 0 open Morelia, Michoacán SUCURSAL sPZZD3kVohiM5VNq101r NnpJAUHzhumbOiHBaQcl 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 24 Días 24 Días 9 Días
28 José Alfredo Villaseñor Arellano José Alfredo Villaseñor Arellano +524436844722 villasenorjosealfredo9@gmail.com Standar PROSPECTO NUEVO 0 Formulario 2026-04-24T12:38:14.241Z 2026-04-27T16:41:51.021Z formulario,contacto existente 0 open LEAD DIGITAL Morelia, Michoacán Yamaha 150 fz16 2011 Sin Dejarlo (GPS) FORMULARIO sr5rylGnLgw2w82pzQy9 i3D8lQqAu2MUK9IhW9Im 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
29 Nora Hinojo Nora Hinojo +524491889754 odisea315@hotmail.com Standar PROSPECTO NUEVO 0 Formulario 2026-04-24T12:32:01.648Z 2026-04-27T16:41:51.739Z formulario,contacto existente 0 open LEAD DIGITAL Morelia, Michoacán Mazda Mazda 3 Hatchback 2018 Sin Dejarlo (GPS) FORMULARIO tatkiV6M5yNMle7CdrJx 0zqinFdhlEuJRHPqYia9 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 24 Días
30 Eduardo Sistos Gonzalez Eduardo Sistos Gonzalez +527531106596 costadeoromich@yahoo.com.mx Standar Intento de contacto 52000 Formulario 2026-04-24T12:38:15.212Z 2026-05-12T13:19:17.559Z 12 12 facebook-ads,follow-up 0 open Lead digital Morelia, Michoacán Sin dejarlo (GPS) FACEBOOK vqRZC0dWlJkYojw4MylX XtBTfPfMGRKsKKP7p8jU 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 27 Días 27 Días 9 Días
31 Luis Antonio Ruiz Gil Luis Antonio Ruiz Gil +524435045807 ruizgilluisantonio@gmail.com Standar Intento de contacto 56671 Formulario 2026-04-24T12:38:13.932Z 2026-05-12T13:19:52.970Z contacto existente,facebook-ads 0 open Lead digital Morelia, Michoacán Honda Odysey 2015 Tradicional (Resguardo) FACEBOOK wCtebdNQhQKIHEs6llVF EFjRaExGbcNtdPrbeeL9 6963723a-54f5-4331-aa82-6ffd1dc7f573 riHamqZZxJEp1hy1QJTg 21 Días 27 Días 9 Días
32 Ariel Undefined ariel +524251282212 Standar PROSPECTO NUEVO 0 2026-04-27T15:48:13.515Z 2026-04-27T23:34:41.156Z sucursal 0 open Morelia, Michoacán. Nissan tida advance 2010 Tradicional (Resguardo) SUCURSAL 7XQvJnHHTrMjNblcRDbe n7iiQlLNyKB0JPCZdLY9 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 24 Días 24 Días 24 Días
33 Roberto Chayanne roberto chayanne +524434184809 Standar PROSPECTO NUEVO 0 2026-04-27T15:48:10.682Z 2026-04-27T23:34:41.230Z sucursal 0 open Redes Sociales Morelia, Michoacán. NISSAN versa 2012 Sin Dejarlo (GPS) SUCURSAL nIfpyRV3WfCdCg1uQweF M6a1HmgiJ6Rg7ewN715C 3948819b-c61e-4f46-8dca-3a45a656e070 riHamqZZxJEp1hy1QJTg 24 Días 24 Días 24 Días
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
contact_classifier.py
Modulo compartido que detecta contactos de test/prueba/E3 a partir de
first_name, last_name, email y tags. Es la fuente de verdad de los
patrones; tanto el script `scripts/find_test_contacts.py` como el endpoint
`/api/contacts/{location_id}` lo usan para mantener consistencia entre el
reporte CLI y la marca visual del dashboard.
Funciones publicas:
- classify_contact(first_name, last_name, email, tags_list) -> (reasons, classes)
- is_test_contact(contact_dict) -> bool
- annotate_contact(contact_dict) -> dict (agrega is_test y test_reasons)
"""
import json
import re
import unicodedata
# Patrones (mismos del script find_test_contacts.py)
SPANISH_PATTERNS = [
r'prueba', r'pruebas', r'probando', r'testeo', r'ejemplo', r'ejemplos',
r'demostracion', r'borrador', r'probar', r'pruebita'
]
ENGLISH_PATTERNS = [
r'test', r'testing', r'tester', r'tests', r'dummy', r'fake', r'mock',
r'example', r'examples', r'demo', r'trial', r'sandbox'
]
E3_PATTERNS = [
r'e3', r'e-3'
]
GENERIC_PATTERNS = [
r'temporal', r'temp', r'generico', r'sdasd', r'asdasd', r'qwerty', r'12345',
r'testea', r'ficticio'
]
# Correos conocidos del equipo E3 que siempre se consideran test, aunque el
# nombre/etiquetas no disparen ningun patron.
TEST_EMAILS = {
"servandobra@gmail.com",
"uriel.conse3@gmail.com",
}
def normalize_text(text):
if not text:
return ""
nfkd = unicodedata.normalize("NFD", str(text))
clean = "".join(c for c in nfkd if unicodedata.category(c) != "Mn")
return " ".join(clean.lower().split())
def _word_match(pattern, text):
"""
True si `pattern` aparece como palabra completa en `text` (limites \\b).
Evita falsos positivos por subcadena: p.ej. 'temp' NO debe matchear dentro
de 'tlatempa', ni 'test' dentro de 'protesta'. Una palabra solo cuenta si
esta delimitada por inicio/fin de cadena, espacios o puntuacion (incluido
el '-' de tags como 'qa-test' o el '@' de un email 'test@...').
"""
if not text:
return False
return re.search(rf'\b{re.escape(pattern)}\b', text, re.IGNORECASE) is not None
def classify_contact(first_name, last_name, email, tags_list):
"""
Devuelve (reasons, classifications). Si reasons es vacio, el contacto NO es de prueba.
"""
first_name_lower = (first_name or "").lower()
last_name_lower = (last_name or "").lower()
email_lower = (email or "").lower()
tags_lower = [t.lower() for t in tags_list] if tags_list else []
reasons = []
classifications = set()
# 0. Correos conocidos del equipo E3 (match exacto).
if email_lower in TEST_EMAILS:
reasons.append(f"Email en lista E3 ({email_lower})")
classifications.add("E3")
# 1. E3 (regex con limites para evitar falsos positivos)
for p in E3_PATTERNS:
pattern_regex = re.compile(rf'\b{p}\b|{p}[-_]|[-_]{p}', re.IGNORECASE)
if pattern_regex.search(first_name_lower) or pattern_regex.search(last_name_lower):
reasons.append(f"Nombre contiene '{p.upper()}'")
classifications.add("E3")
elif p in first_name_lower or p in last_name_lower:
reasons.append(f"Nombre contiene subcadena '{p.upper()}'")
classifications.add("E3")
email_pattern = re.compile(rf'\b{p}\b|^{p}|{p}@|{p}[-_]|[-_]{p}', re.IGNORECASE)
if email_pattern.search(email_lower):
reasons.append(f"Email contiene '{p.upper()}'")
classifications.add("E3")
if any(p in t for t in tags_lower):
reasons.append(f"Etiqueta contiene '{p.upper()}'")
classifications.add("E3")
# 2. Espanol (palabra completa para evitar falsos positivos por subcadena)
for p in SPANISH_PATTERNS:
if _word_match(p, first_name_lower) or _word_match(p, last_name_lower):
reasons.append(f"Nombre contiene '{p}'")
classifications.add("Test/Prueba Espanol")
if _word_match(p, email_lower):
reasons.append(f"Email contiene '{p}'")
classifications.add("Test/Prueba Espanol")
if any(_word_match(p, t) for t in tags_lower):
reasons.append(f"Etiqueta contiene '{p}'")
classifications.add("Test/Prueba Espanol")
# 3. Ingles (palabra completa para evitar falsos positivos por subcadena)
for p in ENGLISH_PATTERNS:
if _word_match(p, first_name_lower) or _word_match(p, last_name_lower):
reasons.append(f"Nombre contiene '{p}'")
classifications.add("Test/Prueba Ingles")
if _word_match(p, email_lower):
reasons.append(f"Email contiene '{p}'")
classifications.add("Test/Prueba Ingles")
if any(_word_match(p, t) for t in tags_lower):
reasons.append(f"Etiqueta contiene '{p}'")
classifications.add("Test/Prueba Ingles")
# 4. Generico (palabra completa: 'temp' no debe matchear dentro de 'tlatempa')
for p in GENERIC_PATTERNS:
if _word_match(p, first_name_lower) or _word_match(p, last_name_lower):
reasons.append(f"Nombre contiene temporal '{p}'")
classifications.add("Temporal/Generico")
if _word_match(p, email_lower):
reasons.append(f"Email contiene temporal '{p}'")
classifications.add("Temporal/Generico")
if any(_word_match(p, t) for t in tags_lower):
reasons.append(f"Etiqueta contiene temporal '{p}'")
classifications.add("Temporal/Generico")
# 5. Nombre o apellido puramente numerico
if (first_name_lower and first_name_lower.isdigit()) or (last_name_lower and last_name_lower.isdigit()):
reasons.append("Nombre o Apellido es numerico")
classifications.add("Temporal/Generico")
return sorted(list(set(reasons))), sorted(list(classifications))
def _coerce_tags(raw):
"""Acepta tags como lista, JSON string o None y devuelve lista de strings."""
if raw is None:
return []
if isinstance(raw, list):
return [str(t) for t in raw if t is not None]
if isinstance(raw, str):
try:
parsed = json.loads(raw)
if isinstance(parsed, list):
return [str(t) for t in parsed if t is not None]
except (ValueError, TypeError):
pass
return []
def annotate_contact(contact):
"""
Recibe un dict de contacto (como lo devuelve db.get_contacts) y le agrega
los campos `is_test` (bool) y `test_reasons` (lista de strings).
Devuelve el mismo dict mutado.
"""
tags = _coerce_tags(contact.get("tags"))
reasons, _classes = classify_contact(
contact.get("first_name"),
contact.get("last_name"),
contact.get("email"),
tags,
)
contact["is_test"] = bool(reasons)
contact["test_reasons"] = reasons
return contact
def is_test_contact(contact):
"""Atajo: True/False sin mutar el dict."""
tags = _coerce_tags(contact.get("tags"))
reasons, _ = classify_contact(
contact.get("first_name"),
contact.get("last_name"),
contact.get("email"),
tags,
)
return bool(reasons)
+73
View File
@@ -0,0 +1,73 @@
import os
SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
os.makedirs(SCRIPTS_DIR, exist_ok=True)
# Lista de scripts de la documentación
scripts_to_create = {
"daily_summary_mp.py": "Resumen diario de contactos y oportunidades en MP main (GbKkBpCmKu2QmloKFHy3). Se ejecuta por cron.",
"ghl_branch_analysis.py": "Análisis paralelo de todas las sucursales MP: contacts, oportunidades, status, discrepancias. (Skill ghl-analytics)",
"sync_contact_sucursal_to_opportunity.py": "Copia el campo Sucursal del contacto a la oportunidad (PUT).",
"fix_sucursal_discrepancies.py": "Corrige discrepancias Sucursal entre contacto y oportunidad.",
"fix_orphaned_pipelines_direct.py": "Detecta y reasigna oportunidades huérfanas (sin pipeline activo).",
"fix_perdido_status.py": "Cambia status de oportunidades a 'perdido'.",
"fix_consultoria_e3_status.py": "Similar al anterior para cuenta de Consultoría E3.",
"migrate_opportunity_stages.py": "Migra/actualiza stages de oportunidades entre pipelines.",
"move_opportunities_pipeline.py": "Mueve oportunidades de un pipeline a otro.",
"update_ano_vehiculo.py": "Actualiza el campo 'Año del Vehículo' en contacts.",
"find_contacts_without_sucursal.py": "Busca contactos sin campo Sucursal en todas las cuentas MP.",
"audit_and_fix_orphaned_pipelines.py": "Audita y repara pipelines huérfanos en todas las cuentas.",
"audit_orphaned_pipelines_readonly.py": "Versión solo lectura de auditoría de pipelines (no modifica, solo reporta).",
"analyze_duplicate_contacts.py": "Analiza y reporta contactos duplicados por teléfono.",
"full_autos_investigation.py": "Investiga contactos con tag 'full autos' y sus oportunidades.",
"full_audit_cross_account.py": "Auditoría profunda cross-account: custom fields, pipelines, contacts.",
"mp_branches_deep_audit.py": "Auditoría completa de custom fields y pipelines en todas las branches.",
"check_multi_pipeline.py": "Detecta cuentas GHL con múltiples pipelines.",
"monitor_no_email.py": "Detecta contactos sin email ni etiqueta 'sucursal' en todas las cuentas MP.",
"health_check_workflows.py": "Verifica que los workflows de GHL estén activos en todas las cuentas."
}
def create_skeletons():
print("Creando esqueletos de scripts...")
for filename, desc in scripts_to_create.items():
filepath = os.path.join(SCRIPTS_DIR, filename)
# No sobreescribir si ya existe (como los 3 que ya programamos)
if os.path.exists(filepath):
print(f"[Existente] Saltando {filename}...")
continue
content = f"""# -*- coding: utf-8 -*-
import os
import sys
def main():
print("=== CONTROL DE SCRIPTS: {filename} ===")
print("Descripción: {desc}")
print("--------------------------------------------------")
print("Iniciando ejecución de prueba...")
print("Leyendo entorno local y base de datos...")
# Comprobar si existe la DB
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from paths import DB_PATH as db_path
if os.path.exists(db_path):
print(f"Base de datos SQLite detectada en: {{db_path}}")
else:
print("Base de datos SQLite no detectada. Requiere sincronización previa.")
print("--------------------------------------------------")
print("Ejecución del script finalizada con éxito.")
if __name__ == '__main__':
main()
"""
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
print(f"[Creado] {filename}")
if __name__ == "__main__":
create_skeletons()
+1048
View File
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
# AGENT_TOOLS — Capa agentica MCP para MP Manager
Este documento describe la capa MCP que permite a Claude Code (y otros clientes
MCP) operar el ecosistema MP Manager como herramienta. Es el **punto de entrada
canónico** para entender qué se expone como tool, cómo invocarla, qué garantías
de seguridad existen y dónde leer cuando algo falla.
> **Para humanos (Uriel)**: lee [GUIA_AGENTICA.md](GUIA_AGENTICA.md) — recetas, prompts copy-paste y cómo trabajar con Claude Code como manager operativo.
> **Para humanos (arquitectura)**: reglas de negocio en `CLAUDE.md` y `AGENTS.md`. Este archivo cubre solo la capa LLM.
> **Para LLM**: cuando arranques una sesión, lee `generated/agent/tools_manifest.json`
> primero — es el inventario actualizado y autogenerado.
---
## 1. Arquitectura
```
Claude Code ── stdio ──► python -m mcp_server
├── adapters.py ──► funciones Python directas (db, sync_*.run_sync, ghl_client)
└── run_script ──► subprocess scripts/*.py --json
generated/agent/runs/*.json (offload de payloads grandes)
```
- **Transport**: stdio. El SDK MCP oficial de Anthropic (`mcp[cli]>=1.0`).
- **Sin HTTP**: la mayoría de tools llaman funciones Python directas. La tool
genérica `run_script` usa subprocess para scripts que no exportan función.
- **Reutiliza el código existente**: `db.py`, `sync_missing_*.run_sync()`,
`script_audit`, `ghl_client`, `paths.*`. El MCP es una capa thin.
---
## 2. Arranque
`.mcp.json` en la raíz del repo:
```json
{
"mcpServers": {
"mp-manager": {
"command": "python",
"args": ["-m", "mcp_server"],
"cwd": "."
}
}
}
```
Claude Code detecta el archivo automáticamente y arranca el server al abrir
sesión en el directorio. Manualmente:
```bash
python -m mcp_server # arranca por stdio
python -m mcp_server.manifest # regenera tools_manifest.json
python scripts/audit_agent_readiness.py # refresca audit_report.json (insumo del manifest)
```
---
## 3. Seguridad — defaults y confirm_token
**Toda tool mutadora arranca con `apply=False`** (dry-run). Para aplicar
cambios reales el LLM debe pasar explícitamente:
```python
apply=True
confirm_token="I-HAVE-USER-CONFIRMATION"
```
Si `apply=True` sin el token correcto, la tool devuelve error sin tocar nada.
El token es literal — no se deriva ni se genera. Su propósito es que el LLM
sea forzado a **pedir confirmación al usuario** antes de incluirlo en la
llamada. Cualquier `apply=True` queda registrado en `script_audit` con un
`run_id` y es reversible vía el dashboard del SPA.
Workflow estándar (protocolo dry-run obligatorio del proyecto):
1. LLM llama tool con `apply=False` → obtiene plan/preview con números reales.
2. LLM resume al usuario los cambios.
3. Usuario confirma explícitamente.
4. **Piloto**: LLM llama con `apply=True` + `confirm_token` aplicando SOLO a una sucursal (filtrar por `location_id` o usar `--location` / args equivalentes).
5. LLM valida el resultado del piloto contra la API/DB y reporta al usuario.
6. Usuario confirma el batch al resto.
7. LLM aplica al lote completo. Cada apply genera `run_id` registrado en `script_audit` (rollback desde el dashboard).
**Nunca saltar el piloto** aunque la lógica parezca trivial. El blast radius en Bucéfalo es alto (50 cuentas, replicación bidireccional, reglas de negocio interdependientes). Si el script no soporta filtrado por sucursal, decirle al usuario y pedir guía antes de aplicar.
---
## 4. Catálogo de tools
| Tool | Categoría | Mutadora | Propósito |
|---|---|---|---|
| `list_accounts` | accounts | no | Lista cuentas (Marca + 49 sucursales). |
| `get_account` | accounts | no | Detalle de una cuenta por `location_id`. |
| `get_global_metrics` | metrics | no | Totales globales (contactos, opps, etc.). |
| `get_account_metrics` | metrics | no | Métricas de una sucursal. |
| `search_contacts` | contacts | no | Busca por nombre/email/teléfono en cache SQLite. |
| `get_contact` | contacts | no | Detalle de contacto por id. |
| `get_opportunities` | opps | no | Opps de una location (opcional `pipeline_id`). |
| `get_pipelines` | opps | no | Pipelines/etapas de una location. |
| `get_workflows` | workflows | no | Workflows de una location o de todas. |
| `sync_missing_contacts` | sync | **sí** | Sucursal→Marca contactos faltantes. |
| `sync_missing_opps` | sync | **sí** | Sucursal→Marca opps faltantes. |
| `sync_logs` | ops | no | Logs recientes de sync. |
| `error_logs` | ops | no | Errores recientes. |
| `agent_audit_report` | ops | no | Reporte de salud agentica. |
| `script_catalog` | ops | no | Inventario completo de scripts y tools. |
| `run_script` | advanced | **sí** | Ejecuta cualquier script de `scripts/` (subprocess). |
### 4.1 Cómo invocar `run_script`
Es la salida para los ~60 scripts no expuestos como tool dedicada. Pasa el
nombre (con o sin `.py`), los args como lista de strings, y opcionalmente
`expect_json=True` si el script soporta `--json` (vuelca a archivo si es grande).
Si el script muta GHL debes pasar `apply=True` + `confirm_token`, además de
los flags propios del script (típicamente `--apply --run-id <uuid>`).
Ejemplos:
```jsonc
// Auditoría read-only con JSON
{ "name": "audit_brand_vs_branches_totals", "args": ["--json"], "expect_json": true }
// Mutador: requiere confirm_token MCP + flags del script
{
"name": "cleanup_cross_branch_duplicates",
"args": ["--apply", "--yes", "--run-id", "<uuid>"],
"apply": true,
"confirm_token": "I-HAVE-USER-CONFIRMATION"
}
```
---
## 5. Manejo de payloads grandes
Cualquier tool puede devolver una de estas dos formas:
```jsonc
// Payload chico: inline
{ "ok": true, "summary": {...}, "details": [...] }
// Payload grande (>8KB serializado): offload a disco
{ "ok": true, "summary": {...}, "report_path": "generated/agent/runs/<tool>_<ts>.json" }
```
Cuando recibas `report_path`, lee el archivo solo si necesitas detalles
específicos. El `summary` está pensado para que decidas si vale la pena.
---
## 6. Recetas frecuentes
### 6.1 Investigar discrepancia de totales Marca vs sucursales
1. `get_global_metrics` para ver el descuadre.
2. `run_script("audit_brand_vs_branches_totals", ["--json", "--show-missing"], expect_json=true)`.
3. Si el reporte sugiere contactos faltantes, `sync_missing_contacts(apply=false)` para preview.
4. Pedir confirmación al usuario → `sync_missing_contacts(apply=true, confirm_token=...)`.
### 6.2 Buscar un contacto y revisar sus oportunidades
1. `list_accounts` para tener la lista de location_ids.
2. `search_contacts(location_id="GbKkBpCmKu2QmloKFHy3", query="<nombre/teléfono>")`.
3. `get_contact(location_id, contact_id)` para detalle.
4. `get_opportunities(location_id)` filtrando por contacto en cliente.
### 6.3 Revisar duplicados cross-branch antes de cleanup
1. `run_script("find_cross_branch_duplicates", ["--json"], expect_json=true)`.
2. Revisar la jerarquía de resolución (ver CLAUDE.md sección "Duplicate resolution rules" en memory).
3. `run_script("cleanup_cross_branch_duplicates", ["--apply", "--yes", "--run-id", "<uuid>"], apply=true, confirm_token="I-HAVE-USER-CONFIRMATION")`.
---
## 7. Reglas críticas heredadas (resumen)
Detalle completo en `CLAUDE.md`, `AGENTS.md` y entries de memory. Lo
indispensable para no romper nada:
- **Cuenta Marca**: `GbKkBpCmKu2QmloKFHy3` (Monte Providencia). Hardcoded en
`sync_engine.py` y `scripts/common.py` como `BRAND_LOCATION_ID`.
- **Sucursales**: 49 locations adicionales del CSV de tokens.
- **Dirección del sync**: contactos bidireccional Marca↔Sucursal, opps
unidireccional Sucursal→Marca (la sucursal manda).
- **Custom fields dinámicos**: nunca hardcodees IDs; usa `common.SchemaResolver`.
- **Marca de producto**: nuestro CRM se llama **Bucéfalo**. No mencionar "Go
High Level" en interfaces de usuario.
- **Servicios E3**: solo digital. No marketing tradicional, no diseño para imprenta.
- **Multi-opp gap**: la replicación Sucursal→Marca (via n8n) solo replica la
primera opp por contacto. Las opps adicionales (multi-empeño) no llegan a
Marca automáticamente — para eso existe `sync_missing_opps`.
- **n8n realtime**: la replicación Marca→Sucursal en tiempo real la hace el
workflow n8n `[1604]`, NO el sync batch.
- **sincorreo@gmail.com**: placeholder de contacto sin correo, causa falsos
matches por email en audits.
---
## 8. Estado del ecosistema (auto-actualizado)
El comando `python scripts/audit_agent_readiness.py` genera
`generated/agent/audit_report.json` con:
- Inventario completo de scripts (77) y su compliance con las convenciones.
- Endpoints FastAPI con clasificación tool-safe.
- Decisión sugerida para huérfanos (no registrados en `SCRIPTS_METADATA`).
- Issues detectados (mutadores sin `--apply`, sin `--run-id`, sin `--json`,
docstrings sin header).
**Snapshot inicial** (2026-05-27): 77 scripts, 49 registrados, 26 huérfanos,
59 endpoints (57 tool-safe), 157 issues totales. La normalización masiva de
issues está fuera de alcance de esta capa MCP — el reporte sirve como gate
para futuras contribuciones.
---
## 9. Convención para scripts nuevos
Para que un script nuevo sea tool-safe automáticamente:
```python
"""<oneliner del script>.
Category: audit | sync | cleanup | fix | migrate | search | browser
Mutates: yes | no
Tool-safe: yes | no
"""
# Args estándar (mutadores):
# --dry-run / --apply
# --run-id <uuid>
# --json
# Exit codes:
# 0 éxito
# 1 error
# 2 dry-run con hallazgos accionables
```
Si es read-only soportar `--json` lo hace consumible por el LLM con
`run_script(..., expect_json=True)`. Si es mutador, además registrar cambios
en `script_audit` para que el rollback del dashboard funcione.
---
## 10. Troubleshooting
| Síntoma | Diagnóstico |
|---|---|
| El server no aparece en Claude Code | Verifica `.mcp.json` en la raíz y que `python -m mcp_server` corra manualmente sin error. |
| `mcp` no instalado | `python -m pip install -r requirements.txt`. |
| Tool devuelve `audit_report.json no existe` | Corre `python scripts/audit_agent_readiness.py`. |
| `apply=True` rechazado | Te falta `confirm_token="I-HAVE-USER-CONFIRMATION"`. Es a propósito. |
| Sync timeout | Subir `timeout_sec` en `run_script`, o lanzar el script directo y monitorear `script_runs`. |
| Resultados inconsistentes con dashboard | El cache SQLite puede estar stale; correr `sync_all_accounts` (via endpoint o script) antes de auditar. |
+202
View File
@@ -0,0 +1,202 @@
# Guía agentica MP Manager
**Para Uriel.** Cómo usar Claude Code como manager operativo experto del ecosistema MP Manager. Si quieres el detalle técnico de las tools, lee [AGENT_TOOLS.md](AGENT_TOOLS.md); esta guía se enfoca en *cómo pedirle las cosas* y *qué esperar de vuelta*.
---
## 1. ¿Para qué sirve esto?
Claude Code es tu manager operativo de MP Manager: le pides auditorías, syncs, búsquedas, fixes o investigaciones, él decide qué tools llamar, te muestra preview, espera tu confirmación y aplica. Reglas de negocio de Monte Providencia (jerarquía de duplicados, Marca↔sucursal, multi-empeño, etc.) ya están en su contexto vía `CLAUDE.md` + memory.
---
## 2. Arrancar una sesión productiva
1. Abre Claude Code en `h:\MegaSync\Proyectos\MP Manager`.
2. Verifica que el MCP está conectado:
> **Prompt**: "Lista las tools de mp-manager que tienes disponibles."
Deberías ver 16 tools: `list_accounts`, `search_contacts`, `sync_missing_contacts`, etc. Si no aparecen, ve a [§8 Troubleshooting](#8-troubleshooting-rápido).
3. (Opcional) Pídele un snapshot inicial para calibrar el estado actual antes de operar — receta 1 abajo.
---
## 3. Contrato de seguridad (cómo funciona desde tu lado)
Todo lo que muta datos en Bucéfalo sigue **siempre** este protocolo de 5 pasos. No hay excepciones — aunque parezca trivial, aunque ya lo hayas autorizado antes para algo similar.
1. **Dry-run primero**. Claude llama la tool con `apply=False` (o el script con `--dry-run`) y te muestra el plan con números reales: cuántas entidades, qué cambia, valor estimado.
2. **Confirmación explícita tuya**. Claude te pregunta antes de mutar. Tú respondes en lenguaje natural ("sí, aplica", "adelante"). Sin tu input no toca producción.
3. **Piloto en 1 sucursal**. La primera aplicación va a UNA sucursal (la que indiques, o Claude propone una de bajo volumen / demo). Se valida el resultado contra la API antes de continuar.
4. **Batch al resto solo tras OK del piloto**. Si el piloto se ve bien, se aplica al lote completo — también con confirmación explícita.
5. **Rollback disponible**. Cada apply genera `run_id` que queda en `script_audit` (reversible desde el dashboard del SPA). Para cambios destructivos también se guarda snapshot en `generated/migrations/`.
**Banderas rojas — pídele preview de nuevo o cancela si pasa esto:**
- Te ofrece aplicar sin haberte mostrado preview primero.
- Quiere ir directo al batch sin piloto (especialmente para fixes nuevos o lógica no probada).
- Aplica y reporta un cambio que no esperabas.
- Te dice "ya lo apliqué" sin run_id.
- No te ofrece rollback cuando algo salió mal.
---
## 4. Recetas de uso frecuente
Cada receta trae el prompt literal, qué tools dispara, y qué esperar.
### 4.1 Snapshot diario del estado
> **Prompt**: "Dame un snapshot del estado actual: totales globales, descuadre Marca vs sucursales si hay, y los últimos errores de los logs."
**Por qué funciona**: arranca `get_global_metrics` + `agent_audit_report` (o `run_script audit_brand_vs_branches_totals --json`) + `error_logs`. Es read-only puro, sin riesgo.
**Qué esperar**: 3 bloques — números globales, lista corta de diferencias por sucursal, top errores con `error_id`.
---
### 4.2 Investigar descuadre de totales
> **Prompt**: "Hay descuadre en el conteo de contactos entre Marca y sucursales. Investiga la causa siguiendo la identidad estándar de descomposición (multiplicidad A-C, missing-in-brand B, solo-Marca D) y prepárame un plan dry-run para corregir lo accionable."
**Por qué funciona**: la frase "identidad estándar" activa la regla `contact_descuadre_reconciliation` de memory. Claude correrá `audit_brand_vs_branches_discrepancy --show-unsynced-contacts --json` y luego `sync_missing_contacts(apply=False)` para preview.
**Qué esperar**: descomposición numérica del descuadre + lista de contactos accionables + propuesta de qué sincronizar.
---
### 4.3 Buscar un contacto a través de sucursales
> **Prompt**: "Busca a `<nombre>` con teléfono `<10 dígitos>` en todas las sucursales y dime en cuáles aparece y si tiene oportunidades."
**Por qué funciona**: `list_accounts` para iterar + `search_contacts` por location. La regla `matching_rules` (phone+name ≥0.80) ya está en memory, así que evita falsos matches por solo teléfono.
**Qué esperar**: tabla con sucursal, contact_id, nombre coincidente, count de opps. Si quieres detalle, pídele "ahora dame las opps del contacto X en la sucursal Y".
---
### 4.4 Detectar y resolver duplicados cross-branch
> **Prompt**: "Corre el detector de duplicados cross-branch y propónme el cleanup usando la jerarquía estándar (valor monetario → status activo → contacto más antiguo → TIENDA). Quiero ver el plan antes de aplicar."
**Por qué funciona**: dispara `run_script find_cross_branch_duplicates --json` y luego `cleanup_cross_branch_duplicates --apply=False`. La jerarquía está en memory (`duplicate_resolution_rules`).
**Qué esperar**: lista de pares duplicados con la decisión propuesta por cada par. Cuando confirmes, ejecuta con `--apply --yes --run-id <uuid>` y te da el `run_id`.
---
### 4.5 Sincronizar contactos faltantes Sucursal → Marca
> **Prompt**: "Sincroniza a Marca los contactos de sucursal que aún no estén replicados. Hazme dry-run primero con muestra de 5 para revisar el payload."
**Por qué funciona**: `sync_missing_contacts(apply=False)`. El parámetro "muestra de 5" hace que Claude limite el preview en el resumen para que sea legible.
**Qué esperar**: muestra de 5 payloads + total proyectado. Tú confirmas → ejecuta con apply=True + confirm_token → te devuelve el run_id.
---
### 4.6 Sincronizar oportunidades faltantes (gap multi-empeño)
> **Prompt**: "Replica las opps adicionales por contacto que el workflow n8n [1604] no copió a Marca (gap multi-empeño). Dry-run primero."
**Por qué funciona**: el "gap multi-empeño" es una regla conocida (memory: `opp_multiplicity_replication_gap`). Claude llama `sync_missing_opps(apply=False)` que usa el campo `ID Oportunidad Sucursal` para filtrar.
**Qué esperar**: número de opps adicionales detectadas por contacto + plan de inserción.
---
### 4.7 Auditar custom fields y tags vs estándar de Marca
> **Prompt**: "Audita el estado de custom fields y tags en todas las sucursales contra el estándar de Marca. Reporta divergencias y propón fix si las hay."
**Por qué funciona**: combina `run_script audit_custom_fields_schema --all --json` + `run_script audit_tags_across_accounts --json`. El estándar (folders Contact/General Info/Additional Info/Block/Form, tags `formulario`/`revisar`/`qa-test`) viene de memory.
**Qué esperar**: tabla de divergencias por sucursal + propuestas (`fix_opportunity_picklist_alignment`, `cleanup_and_unify_tags`, `create_missing_brand_fields`, `apply_custom_field_layout`).
---
### 4.8 Triage de errores recientes
> **Prompt**: "Revisa los errores de las últimas 24 horas y dime qué patrones se repiten. Diagnóstica los top 3."
**Por qué funciona**: `error_logs(limit=200)` + agrupa por `event` / `exception_type`. El playbook de triage Playwright (memory `playwright_log_triage`) se activa si los errores son del browser.
**Qué esperar**: agrupación por tipo de error + lista de `error_id` para profundizar + diagnóstico breve.
---
### 4.9 Rollback de un run que rompió cosas
> **Prompt**: "El run `<run_id>` causó problemas. Revisa qué cambió y prepárame el rollback. No apliques hasta confirmar."
**Por qué funciona**: Claude lee `script_audit.script_change_log` por `run_id`, te muestra qué cambió (planned/applied), y propone revertir vía dashboard del SPA o script de rollback equivalente.
**Qué esperar**: lista de cambios del run + instrucciones para revertir + advertencia si algún cambio dependiente lo bloquea.
---
### 4.10 Tarea ad-hoc con script huérfano
> **Prompt**: "Necesito correr `<nombre_script>.py` con args `<...>`. Antes inspecciónalo, dime qué hace exactamente, si es mutador y qué efectos tiene. Luego dry-run y, si todo se ve bien, aplico yo."
**Por qué funciona**: `run_script(expect_json=True)` para los read-only; para mutadores Claude te explicará qué pide el contrato (`--apply --run-id`) antes de tocar nada.
**Qué esperar**: resumen del docstring + categorización + propuesta de invocación + preview.
---
## 5. Cómo formular prompts que funcionen bien
- **Sé específico con la sucursal**: usa el nombre comercial o el `location_id`. "En Cancún" funciona; "en una sucursal" no.
- **Empieza con preview**: para cualquier cosa que muta, di "dry-run primero" o "muéstrame el plan antes de aplicar".
- **Pide volcado a archivo si esperas reportes largos**: el MCP lo hace automático >8KB, pero decir "guárdame el reporte completo y devuélveme solo el summary" lo fuerza.
- **Apóyate en reglas conocidas**: frases como "usa la jerarquía estándar", "aplica el patrón Monte Providencia", "respeta la regla de matching phone+name" activan memory entries específicas y evitan que Claude reinvente.
- **Acota el alcance al confirmar**: en vez de "sí aplica todo", di "aplica solo los primeros 10" o "aplica solo Cancún". Reduce blast radius.
---
## 6. Qué NO pedirle (límites realistas)
- **Playwright sin sesión configurada**: tocar `ghl_browser_*.py` cuando el `storage_state` o el perfil Chrome no están al día va a fallar. Primero `python start_persistent_profile.bat` o validar `generated/browser/session.json`.
- **Mutaciones masivas sin audit reciente**: pedirle "limpia todos los duplicados" sin haber corrido el detector primero es receta para borrar lo que no querías.
- **Tocar workflows en producción a ciegas**: antes de modificar workflows usa `run_script health_check_workflows`.
- **Cosas fuera del alcance E3**: marketing tradicional, diseño para imprenta, asesoría legal/financiera. Claude te redirigirá al área que corresponda.
- **Imprimir o exfiltrar tokens del CSV**: aunque se lo pidas, no debe; los tokens son secretos.
---
## 7. Evolucionar el ecosistema (loop de mejora continua)
Conforme uses el MCP vas a notar fricción. Pídele a Claude que la resuelva:
- **"Este flujo lo repito mucho — conviértelo en tool dedicada del MCP"** → edita `mcp_server/server.py` y `manifest.py`.
- **"Script X no aparece en el inventario"** → refresca con `python scripts/audit_agent_readiness.py`, y si conviene, regístralo en `SCRIPTS_METADATA` del `script_runner.py` para que también salga en el SPA.
- **"Cuando agregues un script nuevo, asegúrate que cumple la convención"** → docstring header (`Category:`, `Mutates:`, `Tool-safe:`), soporta `--json`, y si muta también `--apply` + `--run-id`. El audit lo verifica.
- **"Hay un patrón repetido en N sucursales que aún no automaticé"** → pídele que diseñe un nuevo script + tool en una sola pasada.
---
## 8. Troubleshooting rápido
| Síntoma | Qué pedirle a Claude |
|---|---|
| No veo las tools de mp-manager | "Verifica `.mcp.json` en la raíz y arranca `python -m mcp_server` manualmente para ver el error de stdio." |
| Claude aplicó algo sin pedir confirmación | "Reporta el `run_id` y haz rollback inmediato. Después explica cómo te saltaste el preview." |
| El reporte es demasiado largo | "Ya debe estar volcado en `generated/agent/runs/`. Dame solo el summary ejecutivo del `report_path`." |
| Los totales no cuadran con el dashboard | "El cache SQLite puede estar stale. Corre `sync_all_accounts` (endpoint o script) y repite el audit." |
| El MCP devuelve `audit_report.json no existe` | "Corre `python scripts/audit_agent_readiness.py` antes de invocar tools que dependen del reporte." |
| Un script huérfano que necesito ya | "Inspeccióna `scripts/<nombre>.py`, dime qué hace, y úsalo con `run_script` siguiendo el contrato." |
| Sospecho que la API de Bucéfalo está rate-limited | "Revisa `error_logs` por códigos 429 y dime si conviene bajar `SYNC_ENGINE_MAX_WORKERS` o reintentar más tarde." |
Si el problema persiste, [AGENT_TOOLS.md §10](AGENT_TOOLS.md#10-troubleshooting) tiene el detalle técnico.
---
## 9. Referencias cruzadas
- [docs/AGENT_TOOLS.md](AGENT_TOOLS.md) — catálogo técnico de las 16 tools del MCP, contrato `confirm_token`, formatos de respuesta.
- [CLAUDE.md](../CLAUDE.md) — arquitectura del backend MP Manager (módulos, flujo de datos, reglas críticas).
- [AGENTS.md](../AGENTS.md) — comandos, gotchas de Bucéfalo, paginación, reglas Monte Providencia.
- [docs/PLAYWRIGHT_SESSION.md](PLAYWRIGHT_SESSION.md) / [PLAYWRIGHT_PATTERNS.md](PLAYWRIGHT_PATTERNS.md) — automatización browser.
- `generated/agent/tools_manifest.json` — inventario navegable autogenerado (tools + scripts + endpoints).
- `generated/agent/audit_report.json` — salud actual del ecosistema (correr `python scripts/audit_agent_readiness.py` para refrescar).
+284
View File
@@ -0,0 +1,284 @@
# Playbook — Investigación de descuadres Marca vs Sucursales
> Metodología de extremo a extremo para diagnosticar y resolver diferencias de conteo
> (contactos / oportunidades) entre la cuenta de Marca (Monte Providencia) y la suma de
> las sucursales, tal como las muestra la **Comparativa** del dashboard.
>
> Documenta la operación del **2026-05-29** (descuadre +5/+5 → 0/+2) como caso de estudio
> y destila el método para casos futuros. Cubre tres niveles: **programación**,
> **business intelligence** y **análisis de perspectivas** (el porqué de las decisiones).
>
> Léelo junto con [AGENTS.md](../AGENTS.md) y las memorias enlazadas al final.
---
## 0. TL;DR — el reflejo correcto
Ante un descuadre, **NO empieces a "arreglar" datos**. Empieza preguntando:
1. **¿El cache está fresco?** La Comparativa lee SQLite, no Bucéfalo en vivo. La causa #1 de un descuadre **positivo** (Marca > sucursales) es cache viejo.
2. **¿Qué dice el signo?** Negativo (sucursal > Marca) ⇒ faltantes / multi-empeño. Positivo (Marca > sucursal) ⇒ cache viejo, huérfanos creados directo en Marca, o fantasmas.
3. **¿Existe de verdad en GHL?** Verifica el contacto sospechoso **por id** (`GET /contacts/{id}`) Y por **índice** (`/contacts/search`). El delta entre ambos revela fantasmas.
Solo después de eso, y siempre con **dry-run → snapshot → apply → re-audit**, se toca algo.
---
## 1. De dónde sale el número
La Comparativa del dashboard se calcula con `run_audit()` en
[`scripts/audit_brand_vs_branches_totals.py`](../scripts/audit_brand_vs_branches_totals.py),
expuesta vía `GET /api/comparativa/marca-vs-sucursales` (`main.py:856`).
**Regla de oro:** `run_audit()` lee de `generated/data/mp_manager.sqlite` (el **cache**), **no**
de la API de GHL en vivo. Por lo tanto el número refleja el estado del **último sync por
location**, no el estado real de Bucéfalo en este instante.
Primer comando de diagnóstico, siempre:
```python
# ¿cuándo se sincronizó por última vez cada location sospechosa?
# sync_log.finished_at vs date_added del contacto sospechoso
# Si el contacto se creó DESPUÉS del último sync de su location -> cache stale, no es huérfano.
```
El audit es **pesado** (fuzzy matching global). Córrelo en background y vuelca a archivo:
```powershell
python scripts\audit_brand_vs_branches_totals.py --json | Out-File -Encoding utf8 generated\agent\runs\descuadre_audit.json
```
(Vía MCP `run_script` con `expect_json=True` puede dar timeout; preferir background + archivo.)
---
## 2. Nivel Business Intelligence — descomponer el descuadre
### 2.1 Identidad de reconciliación
De la memoria `contact_descuadre_reconciliation`:
```
branch_sum brand = (A C multiplicidad) + (B missing-in-brand) (D solo-Marca)
```
- **AC multiplicidad**: un contacto de Marca que matchea con >1 contacto de sucursal (o
viceversa) infla un lado sin inflar el otro.
- **B (missing-in-brand)**: contactos de sucursal que aún no se replican a Marca → bucket
`contacts_in_branch_not_in_brand`.
- **D (solo-Marca)**: contactos que solo viven en Marca → bucket `contacts_in_brand_not_in_any_branch`.
### 2.2 Los 11 buckets de `run_audit()`
El JSON devuelto trae, bajo `missing`, estos buckets (nombres EXACTOS, `audit_…py:12931380`):
| Bucket | Qué detecta |
|---|---|
| `contacts_in_branch_not_in_brand` | Contacto en sucursal sin contraparte en Marca (ni por CF `id_contacto_sucursal` ni por phone/email/name). |
| `contacts_in_brand_not_in_assigned_branch` | Contacto de Marca con TIENDA asignada que no está en ESA sucursal. |
| `contacts_in_brand_present_in_other_branch_not_assigned` | Está en OTRA sucursal, no la asignada por el verificador. |
| `contacts_in_brand_probable_duplicate` | Marca tiene phone/email, no matchea en sucursal, pero hay homónimo con identificadores fuertes en la asignada ⇒ probable duplicado en Marca. |
| `contacts_in_brand_without_tienda` | Contacto de Marca sin el CF `TIENDA` poblado. |
| `contacts_in_brand_with_unknown_tienda` | TIENDA poblada pero no matchea ninguna fila del verificador. |
| `contacts_in_brand_not_in_any_branch` | No aparece en ninguna sucursal. **Fuente típica del descuadre positivo.** |
| `opportunities_in_branch_not_in_brand` | Opp de sucursal sin réplica en Marca (ni por CF `id_oportunidad_sucursal` ni vía contacto). |
| `opportunities_missing_id_field` | Opps con el CF "ID Oportunidad Sucursal" vacío / longitud inválida (calidad de dato, no necesariamente descuadre). |
| `contacts_missing_id_field` | Contactos con el CF "ID Contacto Sucursal" vacío / inválido. |
| `intra_brand_duplicates` | Duplicados DENTRO de Marca (mismo nombre normalizado, sin phone NI email). |
### 2.3 El signo del descuadre es un diagnóstico
- **Negativo** (sucursal > Marca): faltantes en Marca (bucket B) o **multi-empeño** no
replicado (memoria `opp_multiplicity_replication_gap`, causa del histórico 28).
- **Positivo** (Marca > sucursal): casi siempre **cache viejo** o contactos creados directo
en Marca (Facebook / formulario) que no cascaron. Rara vez son duplicados intra-Marca
(verifica `intra_brand_duplicates` para descartarlo de inmediato).
### 2.4 Taxonomía de causas-raíz (observadas en el caso real)
| # | Causa | Cómo se ve | Acción |
|---|---|---|---|
| a | **Cache stale** | Contacto creado después del último sync de su location | Re-sync de la location + re-audit |
| b | **Fantasma de índice GHL** | `/contacts/search` lo devuelve, pero `GET/DELETE /contacts/{id}` da 404 | Esperar a que GHL reconcilie; NO borrar por id |
| c | **Colisión de identidad** | Dos personas comparten teléfono; un registro queda "Frankenstein" | Jerarquía de resolución + dry-run; a veces UPDATE, no DELETE |
| d | **Huérfano pre-fix** | Lead viejo (Facebook) que nunca cascó a sucursal | Bajar con la cascada ya arreglada, o aceptar |
| e | **Sucursal inexistente** | El contacto apunta a una sucursal que no es cuenta MP | Sin arreglo posible; documentar |
---
## 3. Nivel programación — el toolkit y los anclajes
### 3.1 Lectura / diagnóstico (read-only)
- MCP: `get_global_metrics`, `sync_logs`, `error_logs`, `list_accounts`, `get_account_metrics`.
- El audit completo: background + `Out-File utf8` (ver §1).
- `error_logs` es oro: cada error trae el `ghl_response_body` real. Ahí apareció el
`{"message":"Contact not found for id:…"}` que delató el fantasma de índice.
### 3.2 Entender el matching (clave para leer los buckets)
En `audit_brand_vs_branches_totals.py`:
- `build_contact_index(contacts)` (`:430`) → `(by_phone, by_email, by_name)`.
- `find_match(contact, by_phone, by_email, by_name=None, …)` (`:458`): cascada
**phone+name → email → nombre-solo**.
- `MATCH_THRESHOLD = 0.80` (`:53`): similitud mínima de nombre cuando el teléfono coincide.
> **Phone solo nunca es match** (memoria `matching_rules`). Si el teléfono coincide pero el
> nombre diverge < 0.80, es **colisión**, no match. Esta regla es exactamente la que hizo
> que el audit detectara — correctamente — el caso Rebeca/Maely como huérfano en vez de
> fusionarlos a ciegas.
### 3.3 Forense n8n (¿la automatización realmente corrió?)
`scripts/n8n_workflow_lib.py::load_credentials()` lee `n8n/n8n credencials.txt`
(secreto, no imprimir) y devuelve `(api_key, base_url)`. La API se consulta con header
`X-N8N-API-KEY`:
```python
GET /api/v1/workflows?limit=100 # id, name, active
GET /api/v1/executions?workflowId=<id>&limit=15 # startedAt, status, mode
GET /api/v1/executions/<execId>?includeData=true # runData -> camino de nodos
```
Con `includeData=true` se ve **qué nodos ejecutó** cada corrida: así se confirmó que la
cascada Marca→Sucursal **sí** creó el contacto en la sucursal (nodo
`Crear Contacto - Cuenta Objetivo - SUCURSAL` con status success). Workflows clave:
- `4UMRwxJdHFfOGHBp``[1604] Crear Contacto - MARCA A SUCURSAL V2` (dispara por **formulario**).
- `x4DqZ5FtSc43tdzB``[1604] Sincronización Sucursal → Marca - Crear Contacto`.
- `Cfgwp0bOtDW8zuKW` — sync de oportunidades.
El nodo `Obtener Info de cuenta origen - SUCURSAL` expone el `Location_ID` de origen → así se
ubicó que jorge/fernando venían de **Tampico**.
### 3.4 Verificación en vivo vs cache (cómo cazar fantasmas)
```python
import sync_engine as se, requests
tok = {a['location_id']: a['token'] for a in se.parse_accounts_csv()} # NO usar main.TOKENS_CACHE
H = {'Authorization': f'Bearer {tok[LOC]}', 'Version': '2021-07-28', 'Accept': 'application/json'}
# (1) por id -> store principal
requests.get(f'https://services.leadconnectorhq.com/contacts/{cid}', headers=H)
# (2) por índice -> lo que ve la sync
requests.post('https://services.leadconnectorhq.com/contacts/search',
headers={**H, 'Content-Type': 'application/json'},
json={'locationId': LOC, 'query': '<texto>', 'pageLimit': 20})
```
> **`main.TOKENS_CACHE` está vacío fuera del startup de FastAPI** (se puebla en el
> `@app.on_event("startup")`, `main.py:126/141`). Para scripts ad-hoc, cargar tokens con
> `sync_engine.parse_accounts_csv()`.
>
> **Diagnóstico del fantasma:** si (2) lo devuelve pero (1) da 400/404 "Contact not found",
> el contacto está borrado del store pero el índice de search va con retraso → cada sync lo
> revive. No se puede borrar (ya no existe por id); se auto-corrige al reconciliar GHL.
### 3.5 Mutación segura
- `ghl_client.update_contact(token, contact_id, data)``PUT /contacts/{id}` (`:173`).
- `ghl_client.delete_contact(token, contact_id, location_id)``DELETE` (`:176`).
- Tags: pasar el array completo deseado en el PUT (el `DELETE …/tags/{name}` por nombre da
404 — memoria `ghl_tags_api`).
- Endpoint del dashboard `delete_comparativa_contact` (`main.py:2095`) respeta **dry-run**
vía header `X-Dry-Run` (`is_dry_run_request` `:30`, `dry_run_response` `:44`).
> ⚠️ El endpoint del server usa el token cargado en startup. Si falla con 400 raro, prueba
> la mutación directa con el token del CSV (probado: el PUT a Rebeca funcionó con el token
> del CSV cuando el server había dado 400 por el fantasma).
---
## 4. Caso de estudio — la corrida del 2026-05-29
**Estado inicial:** Comparativa marca +5 contactos / +5 opps (Marca de más).
1. **Audit**`intra_brand_duplicates: 0` (no son duplicados) y `contacts_in_brand_not_in_any_branch: 7`. Los 7 eran el origen del exceso.
2. **Triage de los 7:**
- 4 creados **hoy** (adrian, juan carlos por formulario; jorge, fernando por sucursal).
- 1 contacto `qa-test` (test21).
- 2 leads viejos de Facebook (rebeca, guco).
3. **Forense n8n** confirmó que los 4 de hoy **sí** se crearon/replicaron correctamente en
su sucursal (executions success). El problema: sus sucursales se habían sincronizado a las
**13:58**, antes de las altas (20:1020:50) → **cache stale**.
4. **Re-sync** de Villas del Sol, Tulyehualco y Tampico → los 4 matchearon. **Descuadre +5/+5 → 0/+2.**
5. **Caso Rebeca/Maely** (el `contacts_in_brand_not_in_any_branch` restante con seguimiento):
- Un solo contacto en Marca con nombre "Rebeca Gaona" (lead Facebook nov-2025) pero con
`ID Contacto Sucursal`, WhatsApp y **opp** de **Maely Linares** (Puebla, abr-2026).
- Causa: dos personas comparten el mismo teléfono (+52 222 ··· ····); el autoenlace por teléfono
pegó los datos de Maely sobre el contacto de Rebeca → registro "Frankenstein".
6. **Decisión de negocio (usuario):** conservar el registro reciente con seguimiento (Maely),
descartar el viejo sin seguimiento (Rebeca).
7. **Pivote DELETE → UPDATE:** borrar el contacto de Marca habría borrado también la opp de
Maely y la habría dejado huérfana (el registro era, de facto, la réplica de Maely). La
acción correcta fue **sobrescribir la identidad** (nombre/email/tags) a Maely, conservando
opp y enlace. `snapshot → dry-run (diff) → PUT → re-audit`. Resultado: el par cuadra.
8. **test21:** resultó ser un **fantasma de índice**`/contacts/search` lo devolvía
(`total:1`) pero `GET/DELETE` por id daban 404. La sync de las 17:10 lo revivió en cache.
No accionable; se auto-corrige.
**Estado final:** contactos **0**, opps **+2** (opps auto-creadas en vuelo).
---
## 5. Análisis de perspectivas — por qué funcionó
- **Evidencia sobre inferencia.** Cada hipótesis se confirmó contra datos *antes* de actuar:
executions de n8n (¿corrió?), `GET` por id vs search (¿existe?), `sync_log` (¿cache fresco?).
Ninguna mutación se hizo "por intuición".
- **El cache como sospechoso #1.** Tratar el descuadre positivo como problema de cache —no de
datos— evitó "arreglar" 4 contactos que ya estaban perfectos en su sucursal. El re-sync
resolvió más que cualquier mutación.
- **El seguimiento del usuario como timón.** La regla de negocio ("quedarse con el registro
con seguimiento") la puso el usuario; el rol técnico fue **traducirla a la acción correcta
y advertir cuando lo que pidió (borrar) lograba lo contrario**. Esa advertencia evitó
orfanar a Maely.
- **Disciplina dry-run / snapshot.** Convirtió un `PUT` irreversible (sobrescribir identidad)
en algo reversible. El snapshot vive en `generated/migrations/`.
- **Defecto numérico ≠ defecto de integridad.** Rebeca cuadraba a 0 en el contador, pero era
corrupción de identidad real. Se arregló por **integridad de datos**, no por mover el número.
- **Honestidad sobre límites.** test21 (fantasma) y guco (sucursal inexistente) no se forzaron;
se documentaron como no-accionables. Mejor "esto no tiene arreglo y aquí está el porqué" que
un parche cosmético que reaparece en la siguiente sync.
---
## 6. Checklist de investigación (pégalo y síguelo)
1. **Frescura de cache.** `sync_logs` por location vs `date_added` de sospechosos.
2. **Corre el audit** (background + JSON) y **lee los buckets por signo** (§2.3).
3. **Descarta duplicados** intra-Marca (`intra_brand_duplicates`) de entrada.
4. Para cada huérfano sospechoso: **verifica en vivo** por id vs search (§3.4).
- Stale → **re-sync** de la location y **re-audit**.
- Fantasma de índice → **esperar / monitorear** (no borrar por id).
- Creado en Marca → **forense n8n** (¿cascó? ¿a dónde?).
5. **Colisión / duplicado real** → jerarquía de resolución (memoria `duplicate_resolution_rules`)
+ **snapshot → dry-run → apply**. Pregúntate si la acción correcta es UPDATE, no DELETE.
6. **Re-audit final.** Confirma que el descuadre bajó y que no introdujiste orfandad.
7. **Reporta en dos registros:** técnico (este nivel) y **no técnico** para el negocio.
---
## 7. Síntomas → causa probable
| Síntoma | Causa probable |
|---|---|
| El conteo **subió** tras un re-sync | Altas reales nuevas en GHL (no un error). |
| Un contacto **reaparece** tras borrarlo del cache | Fantasma de índice GHL (search lo revive). |
| `GET /contacts/{id}` 404 pero search lo lista | Borrado del store, índice rezagado (fantasma). |
| Huérfano con tag `sucursal` y sin Sucursal/TIENDA poblada | Vino por Sucursal→Marca con formulario sin ruteo. |
| Contacto y su opp tienen **nombres distintos** | Colisión por identificador compartido (teléfono/email). |
| Descuadre positivo con `intra_brand_duplicates: 0` | Cache viejo o huérfanos creados directo en Marca. |
| Mutación falla 400 vía el server pero el dato existe | Token del server distinto; reintenta con token del CSV. |
---
## Referencias
- [AGENTS.md](../AGENTS.md) · [CLAUDE.md](../CLAUDE.md) · [docs/GUIA_AGENTICA.md](GUIA_AGENTICA.md)
- Código: `scripts/audit_brand_vs_branches_totals.py`, `ghl_client.py`, `sync_engine.py`,
`main.py`, `scripts/n8n_workflow_lib.py`.
- Memorias: `positive_descuadre_stale_cache`, `contact_descuadre_reconciliation`,
`matching_rules`, `duplicate_resolution_rules`, `create_duplicate_phone_contact_marca`,
`opp_multiplicity_replication_gap`, `ghl_tags_api`.
+339
View File
@@ -0,0 +1,339 @@
# Playbook — Enlace y regularización de oportunidades Sucursal ↔ Marca
> Metodología para completar el campo de enlace **"ID Oportunidad Sucursal"**
> (`opportunity.id_oportunidad_sucursal`) en oportunidades de Marca (Monte Providencia) y
> para regularizar opps que existen de un solo lado del puente Sucursal↔Marca.
>
> Documenta la operación del **2026-05-29** (10 opps de Marca sin enlace → 9 backfilled +
> 1 regularizada de extremo a extremo) como caso de estudio, y destila el método para casos
> futuros. Cubre tres niveles: **programación**, **business intelligence** y **análisis de
> perspectivas** (el porqué de las decisiones y el loop con el usuario).
>
> Es el complemento "a nivel oportunidad" de [PLAYBOOK_DESCUADRE.md](PLAYBOOK_DESCUADRE.md)
> (que cubre el descuadre de conteo y los fantasmas de contacto). Léelo junto con
> [AGENTS.md](../AGENTS.md) y las memorias enlazadas al final.
---
## 0. TL;DR — el reflejo correcto
Cuando una opp de Marca tiene **"ID Oportunidad Sucursal" vacío**:
1. **No inventes el valor ni borres la opp de entrada.** El campo guarda el **id nativo de la
opp de la sucursal de origen**; es la **clave de idempotencia** que usa n8n para decidir
UPDATE vs CREATE. Llenarlo mal duplica; borrar a ciegas orfana.
2. **El puente es el contacto.** La opp de Marca casi nunca trae el nombre de la sucursal,
pero **su contacto sí trae `id_contacto_sucursal`** poblado. Ese campo te lleva al contacto
de la sucursal, y de ahí a su opp → cuyo id es el valor que buscas.
3. **Verifica por dos métodos independientes** antes de escribir, y **contra la API en vivo**
(no solo el cache), leyendo **la clave correcta** (`fieldValue`, ver §3.4).
4. Si **no existe** la opp espejo en la sucursal, el problema no es de enlace: es una opp que
vive de un solo lado. **Deja que la automatización (n8n) la cree** antes de hacerlo a mano
(§4.2).
Y siempre: **dry-run → confirmación del usuario → piloto de 1 → batch**, con `run_id` y
snapshot para rollback (memoria `feedback_dry_run_protocol`).
---
## 1. El campo de enlace y por qué importa
| Campo | fieldKey | Dónde | Qué guarda |
|---|---|---|---|
| **ID Oportunidad Sucursal** | `opportunity.id_oportunidad_sucursal` | En **Marca**: el id de la opp de la sucursal de origen. En **sucursal**: su **propio** id (self-link). | El enlace 1-a-1 opp Marca ↔ opp sucursal. |
| **ID Contacto Sucursal** | `contact.id_contacto_sucursal` | En **Marca**: el id del contacto de la sucursal. En **sucursal**: su propio id. | El puente a nivel contacto (memorias `opp_self_id_in_branch_field`, `contact_self_id_in_branch_field`). |
**Los IDs de custom field varían por sucursal — nunca los hardcodees.** En esta corrida, en
Marca, resultaron ser `j029pu3OU02ATNccJR6l` (opp link) y `E6lI9ykWhqpj7Pmi7Qd3` (contact
bridge), pero eso es **incidental a Marca**. Resuélvelos por nombre (§3.1).
**Por qué importa el enlace:** el workflow n8n de sync de oportunidades
(`Cfgwp0bOtDW8zuKW`, memoria `n8n_opp_sync_match`) hace match **100% por este campo** (con
fallback por nombre) para decidir si hace UPDATE o CREATE en Marca. Una opp de Marca sin el
campo es una opp "huérfana de enlace": el próximo webhook puede duplicarla en vez de
actualizarla.
---
## 2. Nivel Business Intelligence
### 2.1 El modelo del puente (contacto → opp)
```
opp Marca ──contact_id──► contacto Marca ──[id_contacto_sucursal]──► contacto Sucursal
(sus opps)
opp Sucursal ── su id == valor a escribir
en "ID Oportunidad Sucursal" de la opp Marca
```
La opp de Marca rara vez sabe de qué sucursal viene (el CF de ciudad es la localidad del
lead, no la cuenta). Pero el **contacto** sí: `id_contacto_sucursal` es el ancla. De ahí, las
opps de ese contacto en su sucursal dan el id que falta.
### 2.2 Dos sub-casos, dos acciones distintas
Tras resolver el contacto sucursal, agrupa por cardinalidad **M** (opps Marca del contacto) vs
**N** (opps sucursal del contacto):
| Caso | Qué significa | Acción |
|---|---|---|
| **M=1, N=1** (`match_unique`) | Enlace 1-a-1 limpio | **Backfill** del CF (§3.3) |
| **M=N>1** | Multi-empeño; empareja por (nombre,monto)→nombre | Backfill si el emparejamiento es inequívoco; si no, `review_ambiguous` |
| **M≠N** | Cardinalidades no calzan | `review_count_mismatch` — no tocar, revisar a mano |
| **N=0** (`branch_contact_no_opps`) | El contacto existe en sucursal pero **sin opp** | **No es problema de enlace** → regularizar (§4.2) |
> El caso `branch_contact_no_opps` es la trampa: parece "falta el enlace" pero en realidad
> **falta la opp de origen**. Llenar el campo es imposible (no hay id que apuntar). Ver §4.2.
### 2.3 ¿La opp de Marca es real o un artefacto?
Heurística (memoria `automation_artifact_opportunities`):
- **Artefacto de automatización**: `status=open`, `$0`, y `createdAt ≈ updatedAt ≈
lastStatusChangeAt` (creada segundos después del contacto, nunca tocada). La crea la
automatización al replicar un contacto, aunque la sucursal no tenga opp.
- **Opp real**: tiene movimiento, valor monetario, o status `won`/`lost`.
Esto decide la regularización: un artefacto sin espejo en sucursal **se borra**; una opp real
se conserva y se le crea/empareja el espejo.
### 2.4 Origen del lead: `attributionSource`
El `source` del contacto suele venir `null`. La verdad del origen está en
`attributionSource`:
- `{"medium": "manual", "sessionSource": "CRM UI"}` ⇒ **dado de alta a mano por un agente** en
la sucursal (no formulario, no Facebook). Por eso `source: null`.
- Ausencia de attribution + `source: "Formulario - Sitio Web"` ⇒ lead digital.
En el caso real, esto explicó por qué el contacto existía sin opp: el agente capturó el
contacto en la UI pero **no le creó oportunidad**.
> **Regla de negocio (AGENTS.md):** contactos son bidireccionales Marca↔Sucursal; **opps son
> unidireccionales Sucursal→Marca** (la sucursal es la fuente de verdad). Por eso la
> regularización **crea la opp en la sucursal**, no en Marca, y deja que cascade.
---
## 3. Nivel programación — el toolkit
### 3.1 Resolver nombres de custom field (id ↔ nombre)
Los nombres viven en la tabla `object_schemas` del cache (poblada por sync), con columnas
`location_id, object_key, field_id, field_name, field_key, field_type`:
```python
import sqlite3; from paths import DB_PATH
c = sqlite3.connect(DB_PATH); c.row_factory = sqlite3.Row
r = c.execute("SELECT object_key, field_name, field_key, field_type "
"FROM object_schemas WHERE location_id=? AND field_id=?",
(loc, field_id)).fetchone()
```
Para scripts nuevos, prefiere `scripts/common.py::SchemaResolver` + `FIELD_ALIASES` (resuelve
por **alias** estable, no por id). Verifica también el `field_type`: los `SINGLE_OPTIONS`
exigen que el valor sea una **label** válida del picklist (memoria
`custom_fields_picklist_alignment`); los `TEXT` aceptan texto libre.
### 3.2 El script de backfill (doble método de match)
`scripts/backfill_opp_sucursal_link.py` (memoria `backfill_opp_sucursal_link_script`):
- Solo escribe en **Marca**, idempotente (salta CF ya válido de 20 chars), snapshot en
`generated/migrations/`, audita en `script_audit` por `run_id` (reversible).
- **Importante:** matchea contacto Marca↔Sucursal con `common.match_contacts`
(**phone+nombre**, memoria `matching_rules`), **independiente** del campo
`id_contacto_sucursal`. Esto lo hace un **verificador cruzado**: si tu investigación manual
(vía `id_contacto_sucursal`) y el script (vía phone+nombre) coinciden, tienes dos métodos
independientes de acuerdo → confianza alta.
- Uso programático (permite acotar a un set de opps y correr piloto→batch):
```python
from scripts import backfill_opp_sucursal_link as bf
bf.run_match(opp_ids=[...], dry_run=True) # dry-run
bf.run_match(opp_ids=[UNA], dry_run=False, run_id="...") # piloto de 1
bf.run_match(opp_ids=[LAS_OTRAS], dry_run=False, run_id="...") # batch (mismo run_id)
```
Estados del plan: `match_unique` (aplicable), `review_ambiguous`, `review_count_mismatch`,
`branch_contact_no_opps`, `no_branch_contact`, `phone_collision`, `no_data`.
### 3.3 Crear / borrar / actualizar opps (firmas y payloads)
`ghl_client.py` (vía `sync_engine.ghl_client`):
```python
gc.get_opportunity(token, opp_id) # GET /opportunities/{id}
gc.create_opportunity(token, opp_data) # POST /opportunities/
gc.update_opportunity(token, opp_id, opp_data) # PUT /opportunities/{id}
gc.delete_opportunity(token, opp_id, location_id) # DELETE (params: locationId)
gc._request("GET", "/opportunities/search", token, params={...}) # ver §3.4
```
**Payload de creación** (probado; formato de
`create_opportunities_for_contacts_without_any.py`):
```python
{
"locationId": LOC, "name": "...", "status": "open", "monetaryValue": 0,
"pipelineId": "...", "pipelineStageId": "...", "contactId": "...",
"customFields": [ {"id": cf_id, "value": "..."} ] # CREATE usa {id, value}
}
```
**Payload de update de CF**: el backfill usa `{"id", "key", "field_value"}`; el formato
`{"id", "value"}` también funciona. Para opps nuevas en sucursal, setea su
`id_oportunidad_sucursal` = **su propio id** tras crearla (self-link), aunque n8n tiene
fallback de autoenlace (memoria `n8n_workflows_v2_hardened`).
### 3.4 Gotchas de la API GHL (los que costaron tiempo)
- **`get_opportunity` devuelve los CF bajo la clave `fieldValue`** — NO `fieldValueString`
(que sí usa el endpoint de búsqueda) ni `value`. Leer la clave equivocada da un
**falso negativo** al verificar post-write. En la corrida real, el piloto reportó
`applied=1` pero la primera verificación mostró `None` solo porque buscaba `fieldValueString`;
el dato sí estaba bajo `fieldValue`.
- **`GET /opportunities/search` usa snake_case**: `location_id`, `contact_id` (memoria
`ghl_opportunity_search_quirks`). La respuesta trae `customFields` pero **no** `tags`.
- **No uses paginación por offset ni `GET /opportunities/`** (AGENTS.md); para buscar por
contacto, `GET /opportunities/search?location_id=..&contact_id=..`.
- **Tokens en scripts ad-hoc**: `sync_engine.parse_accounts_csv()`, **no** `main.TOKENS_CACHE`
(vacío fuera del startup de FastAPI — ver PLAYBOOK_DESCUADRE §3.4).
### 3.5 Reversibilidad (obligatoria antes de mutar)
- `script_audit.create_run(run_id, script_name, arguments, locations)` →
`record_change(run_id, loc, "opportunity", obj_id, field_id, field_name, old, new)` →
`mark_change(change_id, "applied")` → `update_run_status(run_id, "completed")`.
- Para DELETE: **snapshot del objeto completo** a `generated/migrations/` ANTES de borrar
(permite recrearlo), y registra el `old_value` con la opp entera.
- Todo lo aplicado con `run_id` es **reversible desde el dashboard**.
---
## 4. Caso de estudio — la corrida del 2026-05-29
**Estado inicial:** 10 opps de Marca con "ID Oportunidad Sucursal" vacío
(CSV `comparativa_opps_missing_id_field`).
### 4.1 Las 9 con espejo — backfill
1. Para cada opp Marca → su contacto → `id_contacto_sucursal` → contacto sucursal (phone
coincidió 100% en las 10) → su única opp → ese id.
2. **Verificación cruzada:** el `dry-run` de `backfill_opp_sucursal_link.py` (que matchea por
phone+nombre, sin usar `id_contacto_sucursal`) devolvió **exactamente los mismos 9 ids** →
dos métodos independientes de acuerdo.
3. **Piloto de 1** (Cosme, la única `won`) → aplicar → **verificar en vivo** (tropezón del
`fieldValue`, §3.4, resuelto) → **batch de 8** bajo el mismo `run_id`
`backfill-opp-link-20260529`. **9/9 verificadas en vivo.**
### 4.2 La 1 sin espejo — Jorge Rosas (regularización extremo a extremo)
**Síntoma:** `branch_contact_no_opps`. El contacto existía en Tampico, pero con **0 opps**.
**Forense:**
- `attributionSource: {medium: manual, sessionSource: CRM UI}` ⇒ el contacto se creó **a mano
en la UI de Tampico** (20:49:57Z), sin opp.
- n8n `[1604]` lo replicó a Marca 4 s después (20:50:01Z).
- Una opp se **auto-creó en Marca** a las 20:50:05Z (`open`, `$0`, `createdAt == updatedAt ==
lastStatusChangeAt`) ⇒ **artefacto de automatización** sin espejo en la sucursal.
- Dato curioso registrado: "Jorge Rosas" es **también el nombre de un agente** de Tampico
(aparece en "Persona que atendió"); el contacto-cliente es entidad distinta (su teléfono).
**Acción (instruida por el usuario, paso a paso):**
1. **Borrar** el artefacto de Marca (snapshot + `delete_opportunity`, audit
`run_id=jorge-rosas-regularize-20260529`).
2. **Crear la opp en Tampico** (la fuente de verdad), pipeline `Standar`
(`ep1d4VpzRezVqWayFbBf`), etapa **PROSPECTO NUEVO**, mapeando del contacto: Modalidad de
Empeño = "Sin Dejarlo (GPS)", Vehículo = "hyundai creta 2017", Fuente de Prospecto =
"SUCURSAL"; luego self-link de su `id_oportunidad_sucursal`.
3. **Esperar 1 minuto** a que n8n (Sucursal→Marca) replique **solo**.
4. n8n **creó** la opp en Marca (~35 s después). **Verificar homologación 100%**: name,
status, $, Modalidad, Vehículo, Fuente y el enlace (`ID Oportunidad Sucursal` de Marca =
id de la opp de Tampico) — **todo coincidió, sin creación manual**.
**Estado final:** 10/10 resueltas (9 backfill + 1 regularizada), y de paso **se validó en
producción que el workflow n8n Sucursal→Marca funciona** end-to-end.
---
## 5. Análisis de perspectivas — por qué funcionó
- **Verificación cruzada por dos métodos.** El enlace se confirmó por `id_contacto_sucursal`
(investigación) **y** por `match_contacts` phone+nombre (el script), de forma independiente.
Coincidir al 100% por dos caminos distintos es lo que justificó aplicar sin dudar.
- **Vivo, no cache — y la clave correcta.** Toda verificación post-write fue contra
`GET /opportunities/{id}` en vivo. El falso negativo del `fieldValue` enseñó que "verificar"
no basta: hay que **leer el campo correcto**. Un `applied=1` no es prueba; el read en vivo sí
(cuando lees bien).
- **Probar la automatización antes de hacerlo a mano.** En vez de crear la opp de Marca
manualmente, se creó la de sucursal y **se le dio 1 minuto a n8n**. Resultado doble: se evitó
una posible **duplicación** (n8n + manual) y se **validó el workflow** en producción. La mano
era el plan B, no el A.
- **Orden de operaciones pensado.** Borrar el artefacto de Marca **antes** de crear la opp en
sucursal garantizó que n8n hiciera un CREATE limpio (no un UPDATE contra una opp basura ni un
duplicado).
- **Reversibilidad primero.** DELETE + CREATE quedaron auditados con snapshot del objeto
completo. Un borrado destructivo se volvió reversible.
- **El usuario como timón; el rol técnico, traducir y advertir.** El usuario fijó la estrategia
(borrar artefacto, crear en sucursal, probar n8n) con autorización **incremental**: "adelante"
para el dry-run, "sí adelante" para piloto→batch, e instrucciones explícitas paso a paso para
la regularización. Cada nivel de mutación esperó su confirmación; la autorización de un paso
**no se extendió** al siguiente más riesgoso.
- **Nombrar cuentas, no ids.** Cada `location_id` se reportó con su nombre (`Tampico`,
`Eugenia`, …): el usuario opera por nombre (memoria `name_account_with_location_id`).
- **Defecto de dato ≠ defecto de número.** El caso Jorge Rosas "cuadraba" como una opp en
Marca, pero era un artefacto sin sustento. Se resolvió por **integridad** (que la opp exista
donde debe y esté enlazada), no por mover un contador.
---
## 6. Checklist (pégalo y síguelo)
1. **Resuelve el field_id** de "ID Oportunidad Sucursal" por nombre (§3.1), no lo hardcodees.
2. Para cada opp Marca con el CF vacío: opp → contacto → `id_contacto_sucursal` → contacto
sucursal → sus opps. Anota **M vs N** (§2.2).
3. **Corre el dry-run** de `backfill_opp_sucursal_link.py` acotado a esas opps y **cruza** sus
`match_unique` con tu resolución manual. Deben coincidir.
4. **Piloto de 1 → verifica en vivo** (`get_opportunity`, clave `fieldValue`) **→ batch** bajo
el mismo `run_id`.
5. Los `branch_contact_no_opps`: **no es enlace, es opp faltante**. Decide artefacto vs real
(§2.3) con el usuario.
- Artefacto sin espejo → **borrar de Marca** (snapshot+audit) + **crear la opp en la
sucursal** + **esperar a n8n** + **verificar homología 100%**.
- Opp real → crear/emparejar el espejo según corresponda.
6. **Re-verificación final en vivo** de cada opp tocada (campo == id de opp sucursal esperado).
7. Si quedaron opps de sucursal nuevas, recuerda el `fill_opp_id_oportunidad_sucursal.py`
(self-link del lado sucursal) para mantener la consistencia que habilita el sync multi-opp.
---
## 7. Síntomas → causa probable
| Síntoma | Causa probable |
|---|---|
| Opp de Marca con "ID Oportunidad Sucursal" vacío y contacto **con** `id_contacto_sucursal` | Opp creada antes de que n8n poblara el enlace → **backfill**. |
| `branch_contact_no_opps` (contacto sucursal sin opp) | El agente capturó el contacto sin crear opp → **regularizar** (crear opp en sucursal). |
| Opp de Marca `open`/`$0` con `createdAt == updatedAt` | **Artefacto de automatización** (se auto-creó al replicar el contacto). |
| `applied=1` pero el read muestra el CF vacío | Estás leyendo `fieldValueString`/`value`; `get_opportunity` usa **`fieldValue`**. |
| Tras crear opp en sucursal, no aparece en Marca | Espera ~1 min a n8n; si no, revisa executions del workflow de opps (ver PLAYBOOK_DESCUADRE §3.3). |
| Contacto con `source: null` pero datos completos | Alta **manual en CRM UI** (`attributionSource.medium = manual`). |
| Dos opps del mismo contacto, una en Marca falta | Multi-empeño no replicado (memoria `opp_multiplicity_replication_gap`). |
---
## Referencias
- [PLAYBOOK_DESCUADRE.md](PLAYBOOK_DESCUADRE.md) · [AGENTS.md](../AGENTS.md) ·
[CLAUDE.md](../CLAUDE.md) · [docs/GUIA_AGENTICA.md](GUIA_AGENTICA.md)
- Código: `scripts/backfill_opp_sucursal_link.py`,
`scripts/create_opportunities_for_contacts_without_any.py`, `scripts/common.py`
(`match_contacts`, `SchemaResolver`), `script_audit.py`, `ghl_client.py`, `paths.py`
(`MIGRATIONS_DIR`).
- Memorias: `backfill_opp_sucursal_link_script`, `opp_self_id_in_branch_field`,
`contact_self_id_in_branch_field`, `automation_artifact_opportunities`,
`opp_multiplicity_replication_gap`, `n8n_opp_sync_match`, `n8n_workflows_v2_hardened`,
`feedback_dry_run_protocol`, `matching_rules`, `ghl_opportunity_search_quirks`,
`name_account_with_location_id`, `create_duplicate_phone_contact_marca`.
+735
View File
@@ -0,0 +1,735 @@
# Patrones probados de Playwright contra Bucéfalo / GHL
Este documento es la post-mortem del trabajo de hacer que el auto-login con 2FA por correo funcione end-to-end. Sirve como **referencia para futuros scripts** de automatización contra la UI de Bucéfalo (o GHL en general).
Va de la mano con:
- [PLAYWRIGHT_SESSION.md](PLAYWRIGHT_SESSION.md) — cómo manejar la sesión (storage_state vs perfil persistente, auto-login con `.env`).
- `memory/ghl_ui_quirks.md` — quirks operativos de la UI de Bucéfalo.
---
## Caso de estudio: el auto-login con 2FA
### Problema original
Tras configurar credenciales en `.env`, el botón "Renovar sesión Bucéfalo" disparaba un subprocess Playwright que debía:
1. Llenar email + contraseña.
2. Click en "Iniciar sesión".
3. Seleccionar el método 2FA "Email".
4. Esperar a que llegara el correo con el OTP.
5. Pegar el código en la UI.
6. Esperar al dashboard.
El primer intento funcionó hasta el paso 2. A partir del 3 el script reportaba "Auto-login falló" — pero el usuario veía que en realidad **el login sí completaba** (la barra de Bucéfalo mostraba "0 h de antigüedad"). Tres bugs encadenados.
### Diagnóstico: cómo lo descubrimos
Sin screenshots de debug, las iteraciones iniciales eran a ciegas. La táctica que funcionó:
1. **Agregar `_save_debug_screenshot(page, label)` en cada punto crítico** del flujo:
- `post_login` (tras enviar credenciales)
- `no_method_selector` (si el detector de método 2FA caduca)
- `before_otp_input` (justo antes de pedir el OTP por IMAP)
- `otp_input_search` (tras detectar dónde tipear el código)
- `code_typed` (con el código ya pegado)
- `final_state` (estado final, sea éxito o fallo)
2. **Mirar las capturas en orden** para reconstruir lo que pasó.
Las capturas revelaron los 3 bugs:
| # | Bug | Síntoma en captura |
|---|---|---|
| 1 | Bucéfalo NO muestra selector de método 2FA — manda el código directo al correo | `post_login` mostró directo la pantalla "Verificar el Código" con 6 inputs vacíos |
| 2 | El `.fill(code)` en un input con `maxlength="1"` solo acepta el primer caracter | `code_typed` mostró "8" en el primer input y los siguientes 5 vacíos |
| 3 | `_wait_for_login_completion` chequeaba solo `page.url`, pero Bucéfalo es SPA y mantiene la URL en `/` aunque el contenido sea el dashboard | `final_state` mostró el dashboard completo, pero el script reportó fallo y la URL seguía siendo `/` |
### Fixes aplicados (en orden de iteración)
**Iteración 1 — Selectores amplios para el método 2FA**: agregué 16 variaciones (button, div[role=button], radio, label, tarjetas con clase `option`/`method`, data-test-id). Hago polling 15 s buscando cualquiera de ellos. Si ninguno aparece, asumo que Bucéfalo mandó el código directo y continúo. — *Bug 1 cubierto.*
**Iteración 2 — Tipeo del OTP con `keyboard.type`**: en lugar de `input.fill(code)` (que rompe con maxlength=1), detecto los 6 inputs de un dígito (`input[maxlength="1"]`, filtrados a visibles), hago click en el primero para foco, y luego `page.keyboard.type(code, delay=80)`. Eso simula tipeo humano caracter por caracter; Vue captura cada keydown y mueve el foco automáticamente al siguiente input. — *Bug 2 cubierto.*
**Iteración 3 — Detección de login completado por DOM + URL**: `_wait_for_login_completion` ahora combina dos señales:
- URL fuera de `/login` o `/auth`.
- DOM ya no muestra el form de login (ningún `input[type="password"]` visible, ni texto "Verificar el Código", ni "Sign in to your account").
Requiere 2 polls consecutivos confirmando ambas señales (~4 s mínimo) para evitar falsos positivos durante la redirección. — *Bug 3 cubierto.*
### Resultado verificado
Corrida final desde terminal:
```
[AUTO] Detectados 6 inputs de 1 dígito (input[maxlength="1"]).
[AUTO] Código tipeado vía keyboard.type (modo: digits).
[INFO] URL actual: https://crm.bucefalocrm.io/
[INFO] URL actual: https://crm.bucefalocrm.io/agency_dashboard?tab=summary
[INFO] Login completado (URL fuera del login + DOM sin form de credenciales).
[ÉXITO] Sesión guardada en: H:\...\generated\browser\session.json
```
Tiempo total: ~30-45 s desde "click en Renovar" hasta sesión guardada.
---
## Lecciones generales para scripts contra Bucéfalo / SPAs
### 1. Nunca confíes solo en la URL
Bucéfalo (y GHL en general) son SPAs Vue/React. Las transiciones internas:
- **Pueden mantener la URL** mientras cambian el contenido (route guard, replaceState, re-render del root).
- **Pueden mostrar el form de login dentro del mismo iframe** cuando una request da 401, sin redirigir a `/login`.
- **Pueden cambiar la URL pero seguir mostrando contenido viejo** brevemente durante la hidratación.
**Patrón a usar**: combinar URL con DOM. Helpers ya existentes:
- `_any_frame_at_login(page)` en [`scripts/ghl_browser_workflow_manager.py`](../scripts/ghl_browser_workflow_manager.py) — detecta login por URL + DOM (input[type=password] visible, textos del form).
- `_looks_like_login_page(page)` en [`scripts/ghl_browser_session_generator.py`](../scripts/ghl_browser_session_generator.py) — versión similar para detectar fin del login.
### 2. Para inputs con `maxlength="1"` (PIN / OTP / tarjetas), usa `keyboard.type` con delay
`locator.fill("123456")` en un input con `maxlength="1"` se trunca y rompe el flujo. La alternativa:
```python
# Detectar inputs de un dígito (más permisivo que `[autocomplete=one-time-code]`)
inputs = page.locator('input[maxlength="1"]').all()
visible = [i for i in inputs if i.is_visible(timeout=200)]
if len(visible) >= 6:
visible[0].click() # foco en el primero
time.sleep(0.3)
page.keyboard.type(code, delay=80) # tipeo humano, Vue auto-advance
```
El `delay=80` ms es suficiente para que Vue/React capture cada keydown.
### 3. Selectores amplios y en cascada
Los selectores de UIs comerciales cambian con cada update. Patrón defensivo:
```python
SELECTORS = [
'button:has-text("Email")',
'button:has-text("Correo electrónico")',
'div[role="button"]:has-text("Email")',
'[data-test-id*="email"]',
'label:has-text("Email")',
# ...
]
deadline = time.time() + 15
while time.time() < deadline:
for sel in SELECTORS:
try:
loc = scope.locator(sel).first
if loc.count() > 0 and loc.is_visible(timeout=300):
loc.click(timeout=2000)
clicked = True
break
except Exception:
continue
if clicked: break
time.sleep(1)
```
Reglas:
- **Probar texto en español Y inglés** — la UI puede estar en otro idioma según preferencia del usuario.
- **No usar `wait_for_selector` rígido con un único selector** — puede caducar 20 s antes de que pruebes otra opción.
- **Hacer polling** dentro del `while time.time() < deadline`.
- **Soportar el caso de "no apareció"** como flujo válido (a veces la UI salta pasos).
### 4. Screenshots de debug en cada punto crítico
Es la herramienta # 1 para diagnosticar. Convención del proyecto:
```python
from paths import SCREENSHOTS_DIR # generated/browser/screenshots/
def _save_debug_screenshot(page, label):
try:
os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = os.path.join(SCREENSHOTS_DIR, f"{label}_{ts}.png")
page.screenshot(path=path, full_page=True)
print(f"[DEBUG] Captura guardada: {path}")
return path
except Exception:
return None
```
Naming: `{prefijo_flujo}_{paso}_{timestamp}.png`. Ej: `autologin_post_login_20260523_184611.png`.
`scripts/cleanup_storage.py` los limpia automáticamente cada 30 días (configurable).
### 5. Reusar browser entre operaciones en bulk
Abrir/cerrar Chromium cuesta 8-10 s por operación. Para bulks con N items, el patrón es:
```python
with sync_playwright() as p:
browser, context, page = _open_browser(p)
_INTERRUPT_STATE["browser"] = browser
_INTERRUPT_STATE["context"] = context
try:
for item in items:
try:
_perform_action_on_page(page, item) # navega a la URL del item
except SessionExpiredError:
# abortar todo el bulk; los demás items también fallarían
break
except Exception as e:
# fallo aislado, continuar con el siguiente
...
time.sleep(2) # no martillar la API de GHL
finally:
_close_and_save(browser, context)
```
Helpers ya disponibles:
- `_open_browser(p)` y `_close_and_save(browser, context)` en `ghl_browser_workflow_manager.py` — manejan el modo (shared `storage_state` vs perfil persistente) y refrescan cookies al cerrar.
- `_INTERRUPT_STATE` global + `_install_signal_handlers()` — garantizan cleanup limpio si el server reinicia o el usuario cancela el task.
### 6. Validar contra la API de GHL, no contra la UI
La UI de Bucéfalo tiene bugs visuales (puede mostrar "Guardado" sin haber guardado, puede no refrescar tras una mutación). La API es la fuente de verdad.
```python
# Después de mutar vía DOM, esperar y reconsultar la API.
def _verify_status_via_api(location_id, workflow_id, target_active,
max_attempts=6, base_wait_sec=3):
for attempt in range(1, max_attempts + 1):
time.sleep(base_wait_sec * attempt) # backoff lineal
wfs = sync_engine.ghl_client.get_workflows(token, location_id)
actual = next((w for w in wfs if w.get("id") == workflow_id), None)
if actual and (actual["status"] in ("active","published")) == target_active:
return True
return False
```
GHL puede tardar hasta 20 s en propagar un cambio del builder a su API → reintentar con backoff es esencial.
### 7. Excepciones tipadas para flujos especiales
Si una condición de error invalida todo lo que sigue (sesión expirada en bulk, login redirect, etc.), levanta una excepción dedicada:
```python
class SessionExpiredError(Exception):
"""Bucéfalo redirigió al login → toda interacción posterior va a fallar."""
pass
```
Y en el caller, captura específicamente para abortar early:
```python
for item in items:
try:
_perform_on_page(page, item)
except SessionExpiredError:
# Marcar los items restantes como skipped sin intentarlos.
for remaining in items[idx:]:
results.append({"status": "skipped", "reason": "sesión expirada"})
break
except Exception as e:
# Error aislado, continuar.
...
```
Eso evita N timeouts de 30s cuando ya sabemos que todo fallará.
### 8. SSE para progreso en tiempo real
Los scripts largos (auto-login, bulks) imprimen líneas al stdout con marcadores que el frontend parsea por regex y traduce a estados humanos:
```python
# Backend (Python script):
print(f"[BULK {idx}/{total}] === '{name}' ({wf_id}) ===")
print(f"[BULK {idx}/{total}] RESULT: {status}") # success|failed|skipped
# Frontend (JS):
const m = line.match(/\[BULK (\d+)\/(\d+)\] RESULT: (\w+)/);
if (m) { /* incrementar contador del status correspondiente */ }
```
Patrón clave: **un marcador `RESULT` único por item** (no contar líneas SKIP separadas — eso causa doble conteo). Ver `bulkParseLine` en [`static/js/app.js`](../static/js/app.js).
Para tareas largas que no son loops, traduce las líneas más relevantes a UI strings:
```js
function _interpretSessionLogLine(line) {
if (/Llenando email \+ contraseña/.test(line)) return { title: "Llenando credenciales…", detail: "..." };
if (/Esperando código OTP por IMAP/.test(line)) return { title: "Esperando el código en el correo…", detail: "..." };
// ...
return null;
}
```
Permite que el modal de progreso muestre algo como `"Esperando el código en el correo…"` en vez de `"[AUTO] Esperando código OTP por IMAP..."`.
### 9. Manejo de modales bloqueantes
Bucéfalo a veces muestra modales tipo "AI Builder habilitado" o "Novedades" que tapan los elementos. Patrón:
```python
_DISMISS_BUTTON_SELECTOR = (
'button:has-text("Entendido"), button:has-text("Got it"), '
'button:has-text("OK"), button:has-text("Aceptar")'
)
def _dismiss_blocking_modals(scope):
try:
btn = scope.locator(_DISMISS_BUTTON_SELECTOR).first
if btn.count() > 0 and btn.is_visible(timeout=500):
btn.click()
time.sleep(0.7)
return True
except Exception:
pass
return False
# Llamar PREEMPTIVAMENTE antes de tu click — y reintentar si reaparece.
for _ in range(3):
if not _dismiss_blocking_modals(scope):
break
# Tu acción:
target.click()
# Y otra vez por si el modal apareció después.
_dismiss_blocking_modals(scope)
```
Si el modal aparece durante un click crítico (toggle de switch), reintentar el click hasta 3 veces verificando el estado deseado.
### 10. Espera explícita por estabilización del estado
Algunos elementos cargan en el DOM antes de tener su estado real bound. Patrón:
```python
# En vez de leer aria-checked inmediatamente:
state = switch.get_attribute("aria-checked") # ⚠ puede ser el default, no el real
# Esperar networkidle (incluso si caduca, el sleep es útil) + margen:
try:
builder_frame.wait_for_load_state("networkidle", timeout=25000)
except Exception:
pass
time.sleep(3) # GHL termina de bindear el estado real
state = switch.get_attribute("aria-checked") # ✓ ahora confiable
```
---
## Anti-patterns observados
| Anti-pattern | Por qué falla | Alternativa |
|---|---|---|
| `confirm()` / `prompt()` / `alert()` nativos del browser | Rompen la estética del dashboard | `appConfirm({...})` / `appPrompt({...})` en `static/js/app.js` |
| `input.fill("123456")` en input con `maxlength="1"` | Solo acepta el primer caracter | `keyboard.type(code, delay=80)` con foco previo |
| `if "login" in page.url` como única señal de sesión expirada | Las SPAs no cambian URL | Combinar con `page.locator('input[type="password"]')` o textos visibles |
| `wait_for_selector` rígido a un selector único | Caduca antes de probar variantes | Polling sobre lista de selectores con `time.time() < deadline` |
| Abrir un Chromium nuevo por cada item de un bulk | 8-10 s perdidos por item | Reusar browser/context entre items con loop interno |
| Trust en el toast "Guardado" del UI como confirmación | Bucéfalo tiene bugs visuales | Reconsultar la API tras la mutación |
| Saltar pausa entre items del bulk | GHL rate-limita y gatilla antibot | `time.sleep(2)` o más entre operaciones |
---
## Plantilla recomendada para un script nuevo
Si quieres escribir un script nuevo de browser-automation contra Bucéfalo:
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tu descripción aquí."""
import os, sys, time
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
# Reusar helpers existentes del manager principal.
from scripts.ghl_browser_workflow_manager import (
_open_browser, _close_and_save,
_save_debug_screenshot,
_any_frame_at_login, SessionExpiredError,
_INTERRUPT_STATE, _install_signal_handlers,
ensure_playwright_browsers, session_file_status,
)
def _perform_action_on_page(page, params):
"""Hace lo tuyo en una página ya abierta. Lanza SessionExpiredError si Bucéfalo
redirige al login. Devuelve True si tuvo éxito."""
target_url = f"https://crm.bucefalocrm.io/.../{params['id']}"
page.goto(target_url, wait_until="domcontentloaded", timeout=30000)
time.sleep(2)
if _any_frame_at_login(page):
raise SessionExpiredError("Bucéfalo redirigió al login")
# ... tu lógica aquí, con screenshots de debug en puntos críticos ...
_save_debug_screenshot(page, "mi_paso_critico")
return True
def main():
_install_signal_handlers()
if not ensure_playwright_browsers():
sys.exit(1)
exists, age = session_file_status()
if not exists:
print("ERROR: Falta generated/browser/session.json. Renueva primero con 'Renovar sesión Bucéfalo'.")
sys.exit(1)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser, context, page = _open_browser(p)
_INTERRUPT_STATE["browser"] = browser
_INTERRUPT_STATE["context"] = context
try:
ok = _perform_action_on_page(page, {"id": "..."})
sys.exit(0 if ok else 1)
finally:
_close_and_save(browser, context)
if __name__ == "__main__":
main()
```
Y si el script debe correr desde el dashboard como subprocess, registra su entrada en `SCRIPTS_METADATA` dentro de `script_runner.py`.
---
## Checklist antes de mergear un script nuevo
- [ ] ¿Los selectores tienen variantes en español **y** inglés?
- [ ] ¿Cada paso crítico guarda screenshot con `_save_debug_screenshot`?
- [ ] ¿La detección de sesión expirada usa `_any_frame_at_login` (URL + DOM)?
- [ ] ¿Las mutaciones validan contra la API tras un margen de 3+ segundos con backoff?
- [ ] ¿Hay manejo de `SessionExpiredError` para abortar early en bulks?
- [ ] ¿El browser se cierra con `_close_and_save` (refresca cookies)?
- [ ] ¿El script registra contexto en `_INTERRUPT_STATE` para que las interrupciones cierren limpio?
- [ ] ¿Las pausas entre operaciones son al menos 2 s?
- [ ] ¿Para inputs maxlength=1, se usa `keyboard.type` con delay?
- [ ] ¿Hay un `RESULT:` único por item si es un bulk (para el parser frontend)?
- [ ] ¿`sys.stdout`/`sys.stderr` reconfigurados a UTF-8 al inicio del script? (evita `UnicodeEncodeError` en Windows cp1252 con `→`, `·`, etc.)
- [ ] Si escanea o lee el **editor visual** de workflows: ¿espera el frame correcto (`client-app-automation-workflows.leadconnectorhq.com`, no el principal) y fuerza el render completo con `#workflow-fit-to-screen`?
---
## Caso de estudio 2: el detector de anomalías en el editor visual de workflows
### Problema
Escanear el canvas de cada workflow buscando tres tipos de anomalía: ícono naranja en nodos (`img#pg-actions__icon--eh-show-error`), IDs de custom field sin resolver dentro del texto del nodo (~20 chars alfanuméricos entre comillas), y avisos globales (`span.n-button__content svg[class*="alert"]`).
El primer escaneo reportó **0 anomalías** en un workflow donde el usuario sabía que existía la cadena `If "Ct0n2f9dvjZNe9npY6WY" no está vac...`. Tomó cinco iteraciones llegar a un detector confiable.
### Lecciones aprendidas (orden de iteración)
#### L1 — El editor visual vive en un iframe externo, no en `crm.bucefalocrm.io`
La page principal (`https://crm.bucefalocrm.io/location/{loc}/workflow/{wf}`) contiene un iframe a `https://client-app-automation-workflows.leadconnectorhq.com/...` donde está el builder real.
`_find_builder_frame()` original buscaba el primer frame con un switch o modal del AI Builder, lo cual funciona para toggles pero no garantiza que sea el frame del canvas. Para escanear nodos hay que **elegir el frame con MÁS matches del wrapper de nodos**, no el primero que tenga ≥1.
```python
# Estrategia que funcionó:
best_frame, best_count = None, 0
for frame in page.frames:
count = frame.locator(NODE_WRAPPER_SEL).count()
if count > best_count:
best_count = count
best_frame = frame
```
Diagnóstico: imprimir `page.frames` con `frame.url` + `count` por frame.
#### L2 — Vue Flow lazy-renderiza nodos: hay que forzar fit-to-screen
El builder de workflows está construido sobre **Vue Flow** (`vue-flow__node`, `vue-flow__minimap`). Vue Flow solo renderiza en el DOM los nodos visibles en el viewport actual — el resto está representado en el minimap pero **no existe como elementos del DOM**.
Sin fit-to-screen, un workflow de 23 nodos puede renderizar solo 2-5 (los visibles al cargar). Con el ícono de "Ajustar a la pantalla" clickeado, los 23 quedan accesibles.
```python
fit_btn = frame.locator('#workflow-fit-to-screen')
if fit_btn.count() > 0:
fit_btn.first.click(timeout=3000)
time.sleep(2)
fit_btn.first.click(timeout=2000) # doble click: el primero a veces solo muestra tooltip
```
IDs útiles del builder (todos están en el frame del iframe externo):
- `#workflow-fit-to-screen` — encajar todo el flujo. **Imprescindible para escaneo full.**
- `#workflow-zoom-in`, `#workflow-zoom-out`, `#workflow-zoom-value` — controles manuales.
- `#workflow-builder-tab-error-highlight` — tab "Resaltar errores" del builder, candidato para futuras detecciones.
- `#workflow-builder-tab-search-and-replace` — útil para buscar IDs específicos sin escanear el DOM.
#### L3 — Los `data-v-*` son hashes scoped de Vue que cambian con deploys
Inicialmente usé `[data-v-aad7ddfb], [data-v-72bc1535]` como selector porque esos hashes aparecían en el HTML que el usuario me compartió. Esos hashes son IDs internos que Vue genera al compilar — **cambian con cada deploy de GHL**. Selectores robustos por estructura/clase:
- `div.rounded-xl.node-shadow` — wrapper de nodos de acción.
- `div.rounded-xl.border-b-4` — wrapper de nodos Branch (border de color según tipo).
- `div.rounded-xl.nopan` — algunos wrappers.
- `#action-node-container` — id del contenedor (se repite por nodo, pero CSS no lo previene).
- `.vue-flow__node` con `data-id="<uuid>"` — wrapper estable del Vue Flow propio, **es el más confiable para identificar nodos individualmente**.
#### L4 — `inner_text()` respeta CSS `truncate`; usa `text_content()`
Los nodos del builder usan `class="truncate"` (Tailwind) para cortar texto visualmente con ellipsis. `inner_text(timeout=...)` de Playwright **devuelve solo el texto visible** según el CSS rendering — pierde caracteres después del corte.
```python
text = node.text_content(timeout=500) or "" # devuelve todo el texto del DOM, ignora truncate
```
Esto es crítico para la regex de IDs (`r'["“”]([A-Za-z0-9]{20})["“”]'`) que requiere las dos comillas: con `inner_text()` la comilla de cierre puede quedar oculta tras la elipsis.
#### L5 — Los íconos de alerta están posicionados absolutos FUERA del wrapper del nodo
El ícono `img#pg-actions__icon--eh-show-error` no es descendiente del `vue-flow__node` correspondiente — Vue Flow lo renderiza como overlay con `position: absolute` en un div separado en el DOM. Subir por `parentElement` no llega al nodo.
Solución: **usar `el.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4')`** que busca el ancestor más cercano con cualquiera de esos selectores. Si no encuentra, fallback a buscar el sibling con texto significativo.
```js
let node = img.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4, div[class*="rounded-xl"]');
// node.getAttribute('data-id') te da el UUID del nodo Vue Flow.
```
Reportar `data-id` del nodo en el output permite al usuario abrir el nodo específico en la UI (los `data-id` son los UUIDs que GHL usa internamente).
#### L6 — UTF-8 en stdout: la flecha `→` mata el script en Windows cp1252
Cuando el script se corre como subprocess desde el dashboard, su stdout va a un pipe. En Windows el encoding default es `cp1252` y caracteres como `→`, `·`, `«»` rompen con `UnicodeEncodeError`. Reconfigurar al inicio del script:
```python
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
# Python <3.7: fallback
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", line_buffering=True)
```
Esto aplica a **cualquier script Playwright nuevo** porque la consola del subprocess y las capturas SSE pasan por esos pipes.
#### L7 — Para inspeccionar el DOM real, dumpea el HTML del frame correcto
Cuando los selectores no matchean lo que esperas, el primer reflejo es ajustar el selector. El paso anterior es **dumpear el HTML real del frame que estás escaneando** y grepearlo. Patrón:
```python
if os.environ.get("ANOMALY_SCANNER_DEBUG"):
html = frame.evaluate("() => document.documentElement.outerHTML")
dump_path = os.path.join(SCREENSHOTS_DIR, f"frame_dump_{wf_id[:8]}.html")
with open(dump_path, "w", encoding="utf-8") as f:
f.write(html)
print(f"[DEBUG] Frame HTML dump: {dump_path} ({len(html)} chars)")
# También logguea cuál frame elegiste vs todos los disponibles:
for fi, f_ in enumerate(page.frames):
cnt = f_.locator(NODE_WRAPPER_SEL).count()
print(f"[DEBUG] page.frames[{fi}]: count={cnt} url={f_.url[:120]}")
```
`frame.evaluate("() => document.documentElement.outerHTML")` retorna el HTML **live** (post-JS), mientras que `frame.content()` puede devolver una versión más temprana.
#### L8 — Las anomalías de campo pueden necesitar contexto semántico, no solo regex
La regex `r'["“”]([A-Za-z0-9]{20})["“”]'` matchea 20 chars alfanuméricos entre comillas. Pero esos 20 chars podrían ser el `workflow_id`, el `location_id`, un UUID de pipeline, o un identificador legítimo. Filtros que aplicamos:
- Descartar si el candidato coincide con `workflow_id` o `location_id` del propio workflow.
- Exigir keywords de contexto en el texto del nodo (`"no está vac"`, `"está vac"`, `"is empty"`, `"es igual"`, `"contiene"`, etc.).
Después de la primera corrida `--all`, revisar manualmente 5-10 hits y ajustar `CONTEXT_KEYWORDS` o endurecer la regex.
#### L9 — **Si la app ya valida algo, no lo re-implementes con heurísticas — pídeselo a la app**
El usuario nos mostró que GHL tiene un botón `#workflow-builder-tab-error-highlight` que se pinta naranja cuando el workflow tiene errores. Al clickearlo, se abre un panel con la lista exacta de nodos problemáticos, sus StepIds y descripciones del error generadas por el propio motor de validación de GHL.
Esto es **infinitamente más confiable** que cualquier heurística externa:
- Idioma-agnóstico (el botón existe con el mismo `id` en español y en inglés).
- Robusto a deploys (el `id` es estable).
- Lo que GHL considera "error" es exactamente lo que el usuario considera "error" en la UI.
- Da info estructurada: `node_name + StepId + descripción exacta del error`.
Patrón resultante (lo que ahora es el **detector primario** del scanner):
```python
def _extract_ghl_native_errors(frame):
btn = frame.locator('#workflow-builder-tab-error-highlight')
if btn.count() == 0:
return []
btn.first.click(timeout=3000)
time.sleep(2.5) # esperar render del panel
return frame.evaluate("""() => {
const out = [];
const stepIdRe = /StepId\\s*:?\\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
// Wrapper de cada error: 'flex flex-col gap-2 py-4 border-b border-gray-200'
const items = document.querySelectorAll('div[class*="border-b"][class*="border-gray-200"][class*="py-4"]');
const seen = new Set();
for (const el of items) {
const txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
const m = stepIdRe.exec(txt);
if (!m || seen.has(m[1])) continue;
seen.add(m[1]);
const cleaned = txt.replace(/^\\s*\\d+\\.\\s*/, '');
const stepIdx = cleaned.toLowerCase().indexOf('stepid');
out.push({
node_label: cleaned.slice(0, stepIdx).trim().slice(0, 120),
step_id: m[1],
description: cleaned.slice(stepIdx).replace(stepIdRe, '').trim().slice(0, 400),
});
}
return out;
}""") or []
```
Aplicación general: **antes de escribir heurísticas para detectar X, busca si la app expone un tab/panel/badge que ya cuente lo que necesitas.** En GHL hay varios candidatos similares que pueden servir para otros escenarios:
- `#workflow-builder-tab-error-highlight` — errores del motor de validación (lo anterior).
- `#workflow-builder-tab-stats-view` — estadísticas de ejecución, ideal para auditar workflows con tasas altas de falla.
- `#workflow-builder-tab-version-history` — historial de cambios.
- `#workflow-builder-tab-search-and-replace` — buscar/reemplazar texto en nodos, atajo para encontrar IDs sin escanear DOM completo.
Las heurísticas externas (escanear DOM con selectores y regex) siguen siendo útiles como **complemento** para detectar cosas que la app NO considera error pero el usuario sí — p.ej. nuestro detector heurístico de `unresolved_field_id` encontró `Ct0n2f9dvjZNe9npY6WY` en un nodo Branch que GHL considera sintácticamente válido (porque la condición `If "X" no está vacío` se evalúa OK como texto literal) pero que para el negocio es un bug porque ese ID nunca se resolvió al nombre del campo.
**Política recomendada para el reporte**:
- La detección nativa de la app es la **fuente primaria**: si reporta un nodo, ese nodo se incluye con info rica (descripción del error oficial).
- Las heurísticas son **complemento**: corren igual, pero si un hallazgo heurístico apunta al MISMO nodo (por `data-id`/StepId) que un hallazgo nativo, se suprime el heurístico para evitar ruido (`if node_id in native_step_ids: continue`).
- Hallazgos heurísticos en nodos NO cubiertos por la app se mantienen — es exactamente donde aportan valor.
### Integración con el dashboard de bulk-operations
Para que el progreso del escaneo se renderice solo en la bulk-bar existente (Publicar/Borrador/Eliminar), el script tiene que emitir exactamente las líneas que `app.js` parsea:
```
[BULK X/N] === 'workflow_name' (workflow_id) en location_id ===
[BULK X/N] RESULT: success|failed|skipped
=== RESUMEN BULK-<TARGET> ===
```
El frontend hace `await mutateFetch(\`/api/workflows/bulk-${target}\`, ...)` con target en `{draft, publish, delete, scan-anomalies}`. Para sumar una operación nueva basta con:
1. Endpoint `POST /api/workflows/bulk-{target}` que escribe batch JSON y dispara el script.
2. Script que acepte `--batch-file <path>` y emita las líneas BULK.
3. Botón en la bulk-bar + función JS que llame `_executeBulkOperation('{target}', items, label)`.
## Lección 19. Paralelismo conservador: N browsers, 1 sesión compartida
**Problema:** los bulks Playwright procesaban workflows uno por uno (~30-50s c/u). Un scan/bulk completo sobre las 50 cuentas tomaba horas.
**Intento fallido (no replicar):** "1 proceso → 1 `Browser` → N `BrowserContext` en threads" — la sync API de Playwright lleva un **greenlet event loop por thread**; cualquier objeto creado en el thread main (`Browser`, `Context`, `Page`) explota al usarse desde otro thread con:
```
greenlet.error: cannot switch to a different thread (which happens to have exited)
```
**Solución que funciona:** cada worker thread abre su **propio** `sync_playwright().start()` + `browser.launch()` + `new_context(storage_state=SESSION_FILE)`. La sesión sí se comparte (1 login → N browsers reusando cookies), pero cada thread tiene su event loop dedicado.
Implementación en `ghl_browser_workflow_manager._run_parallel_bulk` y `ghl_browser_workflow_anomaly_scanner.scan_workflows_parallel`.
**Topología real:**
```
1 proceso → N threads (ThreadPoolExecutor)
└── cada thread: sync_playwright().start()
└── chromium.launch() ← N browsers Chromium
└── new_context(storage_state=SESSION_FILE)
└── new_page()
```
**Reglas duras:**
1. **NUNCA paralelizar con perfil persistente** (`GHL_BROWSER_PROFILE_DIR`). Chromium bloquea el profile dir → segundo browser que intenta abrirlo cuelga. Detectar al inicio y forzar `workers=1`.
2. **NUNCA paralelizar el login.** OTP por IMAP es un buzón compartido — 2 procesos compitiendo por el mismo correo se pisan. Genera 1 sesión secuencial, todos los workers reusan `session.json`.
3. **Cada thread crea su propio `sync_playwright().start()` + `browser`.** No intentes compartir un Browser entre threads — la sync API tiene un greenlet loop por thread y los objetos están atados al loop que los creó.
4. **`storage_state` se persiste best-effort al cerrar cada context.** Cookies refrescadas por GHL durante el run quedan en `session.json` para la próxima ejecución. No es transaccional; con N workers, gana el último.
5. **Cancelación cooperativa con `threading.Event`.** Cuando un worker captura `SessionExpiredError`, setea `cancel_event`; los demás revisan en el bucle y salen limpio. No swallow de excepciones.
6. **Pausa entre items, no entre workers.** `per_action_pause` (3s default mutador, 1s read-only) por worker, interrumpible mediante chequeo de `cancel_event` en sleep granular (`step=0.25s`).
7. **Agrupar la queue por `location_id`.** Reduce navegación lateral y refresh de cookies cross-sucursal.
8. **Cada cambio del mutador sigue auditándose con `script_audit`.** SQLite serializa writes; activamos WAL en `init_audit_db` para evitar `database is locked` con 2-5 workers.
9. **Tope de 5 workers.** Más allá, el cuello deja de ser CPU/browser y empieza a ser anti-bot/throttling de Bucéfalo. La ganancia plana se vuelve riesgo.
10. **Métricas en `generated/logs/parallel_runs.jsonl`** (append). Comparar `duration_s` y `failed/skipped` entre runs para detectar regresiones o señal de throttling.
**Cuándo subir workers:**
- Read-only (scanner): hasta 5 sin problema.
- Mutador (manager bulk-draft/publish/delete): empezar en 2, máximo 3-4. Cada acción dispara API validate post-mutación.
- Si ves `[METRICS] failed` creciendo o `SESIÓN EXPIRADA` en bulks que antes pasaban → bajar workers, subir `--action-pause`.
**CLI:**
```bash
# scanner read-only, agresivo
python scripts/ghl_browser_workflow_anomaly_scanner.py --all --workers 4 --action-pause 1
# bulk-draft conservador
python scripts/ghl_browser_workflow_manager.py --action bulk-draft \
--batch-file generated/runtime/batch/draft_batch.json --workers 2 --action-pause 3
```
**Verificación:** ver `[METRICS] start` / `[METRICS] end` en stdout. Run a workers=1 como baseline, luego workers=N, comparar `rate` y `failed`.
**Resultados medidos (2026-05-28, scanner read-only sobre Bucéfalo):**
| Workers | Items | Tiempo | s/item | Speedup |
|---------|-------|--------|--------|---------|
| 1 | 6 | 312.9s | 52.2 | baseline |
| 2 | 6 | 192.8s | 32.1 | **1.62×** |
| 4 | 12 | 200.9s | 16.7 | **3.13×** (vs baseline extrapolado) |
No hubo `SessionExpiredError` ni fallos en ninguna iteración. Cada browser extra consume ~150-200 MB RAM en headless; con 4 workers son ~800 MB-1 GB adicionales — cómodo en una máquina con 8 GB+.
Las acciones por fila individual reusan el mismo flujo con `items = [oneItem]` — no hace falta endpoint separado.
## Lección 20. Auditoría granular paralela + observabilidad
El manager Playwright **registra cada mutación en `script_audit`** siguiendo el patrón gold-standard de `sync_missing_opps_to_brand.py`:
```python
# antes del intento
change_id = script_audit.record_change(run_id, location_id, "workflow", wf_id,
field_id="status", field_name="status",
old_value=current, new_value=target) # status = "planned"
# tras la mutación + validación API
script_audit.mark_change(change_id, "applied") # éxito
# o
script_audit.mark_change(change_id, "failed", msg) # cualquier path de error
```
Inyectado en `_perform_toggle_item`, `_perform_delete_item`, y `rename_via_playwright_dom`. Thread-safe (SQLite con WAL + busy_timeout=30s; nuevas conexiones por llamada via `script_audit.get_conn`).
**Bootstrap idempotente**: tanto manager como scanner llaman `script_audit.create_run(...)` (que usa `INSERT OR IGNORE`) al inicio de `main()`. Esto garantiza que runs lanzados desde CLI (sin pasar por el dashboard) tampoco queden huérfanos.
**Log estructurado por-run**: `script_logger.RunLogger` (en raíz) escribe una línea JSONL por evento a `generated/logs/script_runs/{run_id}.jsonl`. Cada línea: `{ts, level, run_id, event, worker_id, location_id, workflow_id, status?, change_id?, error_id?, duration_ms?}`. Thread-safe (lock interno) — los N workers paralelos escriben sin race.
### Las 4 fuentes de verdad para forensics
| Síntoma / pregunta | Dónde mirar |
|---|---|
| ¿Qué se intentó/aplicó? | `script_audit.get_run_summary(run_id)` o `python scripts/audit_run.py <run_id>` |
| Bulk falló a la mitad | `generated/logs/script_runs/{run_id}.jsonl` (cronología por worker) |
| Error específico de Playwright | `generated/logs/errors.jsonl` filtrar por `error_id` o por `context.run_id` |
| Performance / speedup | `generated/logs/parallel_runs.jsonl` |
| Estado visual al fallar | `generated/browser/screenshots/*_{loc}_{wf}.png` |
| Listado rápido de runs recientes | `python scripts/audit_run.py --list 20` |
### CLI de inspección — `scripts/audit_run.py`
```bash
python scripts/audit_run.py <run_id> # resumen + cambios + eventos + errores
python scripts/audit_run.py --list 20 # últimos 20 runs con counts
python scripts/audit_run.py <run_id> --json # JSON crudo (para jq/pipes)
python scripts/audit_run.py <run_id> --events 50 # más eventos del JSONL
```
Combina las 4 fuentes en una sola vista. No depende del dashboard ni de FastAPI.
### Sobre rollback
`script_audit.rollback_run` revierte mutaciones de **custom fields** via PUT. Las mutaciones DOM de workflows (toggle/delete/rename) **se registran pero no son auto-revertibles** — el undo manual es ejecutar el bulk inverso:
| Acción original | Reversión |
|---|---|
| bulk-draft | bulk-publish del mismo batch |
| bulk-publish | bulk-draft del mismo batch |
| bulk-delete | recrear (no recuperable desde GHL) — confirmar antes de aplicar |
| rename | rename con el old_value que guardó `script_change_log` |
Eso queda documentado y trazable; ningún cambio se pierde en el éter.
+206
View File
@@ -0,0 +1,206 @@
# Sesión persistente de Bucéfalo en Playwright
Este documento explica cómo funcionan los scripts del repo que automatizan la UI de Bucéfalo CRM con Playwright (toggle de workflows, renombrar, eliminar, etc.), por qué a veces se cae la sesión, y cómo recuperarla.
Audiencia: quien mantiene `scripts/ghl_browser_*.py` o usa el dashboard para mutar workflows. Si alguien (humano o Claude) llega aquí buscando "por qué falla el delete/toggle/rename de workflows", este es el lugar.
---
## Por qué necesitamos Playwright
La API oficial de GHL (`services.leadconnectorhq.com`) no expone todas las operaciones — en particular:
- Toggle Borrador ↔ Publicado de un workflow
- Renombrar un workflow
- Eliminar un workflow
Para esas tres acciones, los scripts automatizan **la UI web** con Playwright sobre Chromium en modo headless. Toda la lógica vive en [scripts/ghl_browser_workflow_manager.py](../scripts/ghl_browser_workflow_manager.py); la sesión se genera con [scripts/ghl_browser_session_generator.py](../scripts/ghl_browser_session_generator.py).
---
## Dos modos de sesión
Los scripts soportan dos modos de persistencia. Se elige con la variable de entorno `GHL_BROWSER_PROFILE_DIR`.
### Modo 1 — Shared `storage_state` (por defecto)
- **Cuándo se usa**: cuando `GHL_BROWSER_PROFILE_DIR` no está definida (el caso normal).
- **Cómo funciona**: el archivo `generated/browser/session.json` contiene cookies + localStorage. Cada script abre un browser limpio con esas cookies, hace lo suyo, y guarda las cookies actualizadas de vuelta en `generated/browser/session.json`.
- **Pros**: simple, soporta scripts en paralelo (el archivo se lee al inicio y se escribe al final, no hay locks).
- **Cons**: GHL puede interpretar cada arranque como un "navegador nuevo" porque no hay IndexedDB ni cache compartido. Si GHL invalida la sesión por detectar dispositivos múltiples, este modo se cae más rápido.
### Modo 2 — Perfil de Chrome persistente
- **Cuándo se usa**: cuando defines `GHL_BROWSER_PROFILE_DIR` apuntando a un directorio. Lo más fácil es lanzar el servidor con [start_persistent_profile.bat](../start_persistent_profile.bat).
- **Cómo funciona**: Playwright usa `launch_persistent_context()` con un perfil completo en disco — igual que un Chrome real. Persiste cookies HttpOnly, IndexedDB, cache, localStorage, service workers.
- **Pros**: GHL trata el perfil como un "dispositivo" estable. Sesión mucho más duradera. Login dura semanas en vez de horas/días.
- **Cons**: **no puedes correr dos scripts en paralelo** contra el mismo perfil — Chrome bloquea el directorio mientras un proceso lo usa. Si necesitas ejecuciones concurrentes (raro en este repo), no uses este modo.
---
## Auto-login con 2FA por correo (IMAP)
Si configuras un archivo `.env` con las credenciales de Bucéfalo + IMAP de tu correo, el `ghl_browser_session_generator.py` hace **todo el login solo**: llena email + contraseña, selecciona "Email" en el selector de método 2FA, lee el código del correo vía IMAP y lo pega.
### Configuración
1. Copia `.env.example` a `.env`:
```cmd
copy .env.example .env
```
2. Edita `.env` y rellena:
- `BUCEFALO_LOGIN_EMAIL` / `BUCEFALO_LOGIN_PASSWORD` — credenciales de tu usuario de Bucéfalo.
- `EMAIL_IMAP_HOST` — servidor IMAP de tu correo (típicamente el dominio de tu hosting, ej. `c1101854.sgvps.net`).
- `EMAIL_IMAP_PORT` — 993 (IMAPS) por defecto.
- `EMAIL_IMAP_USER` / `EMAIL_IMAP_PASSWORD` — credenciales de la cuenta de correo a la que llega el código MFA.
3. Verifica que el `.env` esté excluido de Mega/Git (el `.megaignore` y `.gitignore` del repo ya lo cubren).
### Comportamiento
- Si están **todas** las credenciales en `.env`, el session generator corre en `headless=True` (sin abrir ventana) y completa el login en ~30-60 s.
- Si **falta alguna**, cae al modo manual: abre el navegador visible para que tú completes el login.
- Si quieres forzar el modo manual aunque haya credenciales, usa `--no-auto`:
```cmd
python scripts/ghl_browser_session_generator.py --no-auto
```
### Probar el lector IMAP por separado
Si solo quieres validar que IMAP funciona y el parser extrae el código:
```cmd
python scripts/email_otp_reader.py
```
Te pide que provoques un código (intenta loggearte a Bucéfalo) y al recibirlo lo imprime.
### Seguridad
- El archivo `.env` contiene credenciales en texto plano. Manténlo solo en tu equipo local.
- Si crees que el `.env` pudo haberse copiado a un lugar no seguro (cloud público, repo público, captura de pantalla), cambia las contraseñas inmediatamente.
- Los `.env.example` y `.megaignore` del repo están diseñados para evitar que esto pase por descuido.
## Cómo arrancar cada modo
### Modo 1 (default)
```cmd
start.bat
```
Si nunca generaste sesión, en el dashboard ve a la pestaña **Workflows GHL** y dale al botón **"Renovar sesión Bucéfalo"**. Inicia sesión + MFA en la ventana que se abre — el archivo `generated/browser/session.json` se crea solo.
### Modo 2 (perfil persistente)
```cmd
start_persistent_profile.bat
```
El .bat:
1. Setea `GHL_BROWSER_PROFILE_DIR=<repo>/generated/browser/profile`.
2. Lanza `python main.py` con esa variable en el entorno.
La primera vez te toca generar el perfil:
1. En el dashboard, dale a **"Renovar sesión Bucéfalo"**.
2. Login + MFA en la ventana.
3. El perfil queda guardado en `generated/browser/profile/`. Próximos arranques no piden login.
> ⚠️ Si quieres volver al modo 1, basta con cerrar el server y abrirlo con `start.bat`. Los dos modos no se mezclan: el modo 1 usa `generated/browser/session.json`, el 2 usa `generated/browser/profile/`.
---
## Síntomas y diagnóstico
### "Chromium me cerró la sesión de mi navegador personal"
GHL detectó dos sesiones activas (la tuya y la de Playwright) e invalidó la más vieja.
- **Modo 1**: muy probable. Cada arranque "huele" a dispositivo nuevo.
- **Modo 2**: poco probable. El perfil persistente se ve como el mismo dispositivo.
**Solución**: cambia al modo 2 con `start_persistent_profile.bat`.
### "ERROR: No se pudo interceptar la sesión de usuario de GHL"
El script (ya no usa este flujo, pero el mensaje puede aparecer en versiones viejas) o el flujo DOM no pudo cargar la página porque te redirigieron al login.
**Solución**: renueva la sesión desde el botón del dashboard. Si pasa muy seguido, cambia al modo 2.
### "Sesión Bucéfalo: 49.5 h de antigüedad" en el dashboard
El indicador muestra la antigüedad de `generated/browser/session.json`. Una sesión vieja **no garantiza** que esté caducada — GHL puede aceptarla. Pero a partir de 24 h, el dashboard advierte.
- Si la corrida falla, renueva la sesión.
- Si la corrida funciona, el archivo se actualiza solo (el script refresca cookies al cerrar).
### "ERROR de navegador: BrowserType.launch: Executable doesn't exist at …"
Faltan los binarios de Chromium. El script intenta instalarlos solo en el primer fallo:
```cmd
python -m playwright install chromium
```
### "El cambio NO persistió en GHL"
Tras un toggle exitoso visual, la API de GHL sigue devolviendo el estado viejo. Causas:
- El workflow tiene un trigger inválido (Webhook sin URL, etc.) y GHL revierte el cambio en silencio.
- Bug visual de la UI — el script ya hace un reload + revalida automáticamente.
Si el script reporta este error después de los reintentos, abre el workflow en el navegador, revisa los triggers, y vuelve a intentar.
### "Proceso interrumpido (signal …)"
El subprocess de Playwright fue matado externamente — típicamente porque reiniciaste el server o cancelaste el task desde el dashboard. El handler de señales:
- Cierra Chromium limpio (sin zombies).
- Refresca cookies en `generated/browser/session.json`.
- Consulta la API y te dice si el cambio **sí se aplicó pese a la interrupción**.
Si fue antes del click del switch: no pasa nada, reintenta.
Si fue después: revisa el log — la línea `[INTERRUPCIÓN] El cambio SÍ se aplicó` te lo dice.
---
## Reglas para mantener los scripts
Si tocas `scripts/ghl_browser_*.py`:
1. **No abras un `browser = p.chromium.launch(...)` manual**. Usa `_open_browser(p)` — respeta el modo (shared / persistent).
2. **No cierres con `browser.close()`**. Usa `_close_and_save(browser, context)` — refresca cookies y limpia el estado del handler de interrupciones.
3. **Registra el browser y context en `_INTERRUPT_STATE`** justo después de abrirlos. Si no, los handlers de interrupción no podrán cerrarlos.
4. **No confíes en la UI para validar mutaciones**. La fuente de verdad es la API de GHL. Tras cualquier guardado visual, llama a `_verify_status_via_api(...)` (o un equivalente) con reintentos.
5. **Selectores frágiles**: si GHL cambia el HTML, los selectores hardcodeados (`#cmp-header__btn--save-workflow`, `.n-switch`, textos "Eliminar flujo de trabajo"/"Delete workflow") pueden romperse. Tomar screenshot de debug es el primer paso de diagnóstico (`_save_debug_screenshot(page, "label")`).
---
## Cómo reportar un error útilmente
Cuando un script de Playwright falla y necesitas ayuda (humano o IA), incluir esta información acorta el diagnóstico de horas a minutos:
1. **El `error_id` completo**. Aparece en los logs como `error_id=550af7d5-…`. Permite consultar [error_log](../db.py) con todo el contexto (return_code, comando exacto, últimas 80 líneas de output).
2. **El `task_id`** si lo ves (aparece como `(task d819f354-…)`). Permite cruzar con `script_runs` para ver el estado de la auditoría.
3. **Las últimas 20-30 líneas del log** que viste. Aunque el `error_log` ya las guarda, a veces el truncado pierde la línea más informativa.
4. **Si hay screenshots en `generated/browser/screenshots/`** con timestamp cercano al fallo: nómbralos. Los scripts guardan capturas en cada punto crítico (`delete_no_row_*`, `switch_no_change_*`, `post_save_state_*`, etc.).
5. **El estado de la sesión**: ¿cuánto tiene `generated/browser/session.json`? El dashboard te lo dice arriba de la tabla de Workflows. Si tiene más de 24 h, renueva primero y reintenta antes de pedir ayuda.
6. **Qué intentaste hacer**: acción (`toggle-status`/`delete`/`rename`), `location_id`, `workflow_id`, y qué esperabas que pasara.
Ejemplo de un buen reporte:
> El toggle-status del workflow `67f98059-…` en `Z64WQKORPVwXb5mn68Ef` falló a las 14:03.
> Log dice `error_id=9f58fa5d-…` y `[ERROR] La tabla de workflows no cargó`.
> Screenshot: `generated/browser/screenshots/delete_list_failed_20260523_142713.png`.
> La sesión tiene 12 h, renovada hoy en la mañana.
Con eso ya se puede empezar a diagnosticar sin tener que pedir info de vuelta.
## Comportamiento conocido de la UI de Bucéfalo
Documentado también en `memory/ghl_ui_quirks.md` para sesiones de Claude:
- El builder de workflows carga dentro de un iframe en `client-app-automation-workflows.leadconnectorhq.com`. Cualquier selector debe operar sobre ese frame, no sobre la página principal.
- El listado de workflows está en otro iframe distinto.
- Al cargar el builder, el switch de Publicar/Borrador arranca en `aria-checked=false` y luego cambia cuando el cliente recibe el estado real. Esperar 25-30 s antes de leer.
- El botón "Guardar/Save" dice "Guardado/Saved" por defecto **incluso sin cambios**. No es señal de estado cargado.
- Aparece un modal "AI Builder habilitado" con botón "Entendido" que tapa el switch — hay que cerrarlo preemptivamente, y a veces reaparece tras el click. Por eso el script reintenta el toggle hasta 3 veces.
- Tras "Guardar", GHL puede tardar **hasta 20 s** en propagar el cambio a la API. La validación contra API hace 6 reintentos con backoff incremental.
- A veces se necesita un F5 manual para destrabar bugs visuales — el script lo hace automático tras los primeros reintentos.
@@ -0,0 +1,78 @@
---
id: CASE-2026-05-29-backfill-cf-vehiculo-temixco-marca
fecha: 2026-05-29
categoria: custom_field | cascada_n8n
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "yjqKxoO02rsdwdJZSPmD (85950 - MP - Temixco)"]
run_ids: ["4b26f163-f87c-48c5-be53-462b2e6f53da"]
snapshots: []
status: parcial
memorias: [[sucursal_to_marca_cf_drop_on_create]], [[positive_descuadre_stale_cache]], [[n8n_realtime_replication]]
playbooks: [docs/PLAYBOOK_DESCUADRE.md]
---
## TRIGGERS
- `contacto en Marca sin datos de vehículo`, `customFields count: 0 en Marca`, `réplica Sucursal→Marca sin custom fields`
- `Sincronización Sucursal` (source) + tag `sincronizado-sucursal` con CFs vacíos
- `Cristhian laura ramirez juarez`, `Hugo Lopez`, `Temixco`, `+527772582548`, `+527772162523`
- `nombres de custom field idénticos entre sucursal y Marca` (descarta name-mismatch)
- `Export_Contacts_leads` CSV con columnas de vehículo vacías
## SÍNTOMA
CSV export de Marca: 2 leads de Temixco (`Cristhian` `U9DWipeW9XhQTZZMzFl9`, `Hugo` `bUNqMZaLI1QCn4yQM6qT`)
llegaron a Marca **sin** Versión/Marca/Año de vehículo, Modalidad, Fuente, Sucursal. Edgar (Tampico) sí llegó completo.
## DIAGNÓSTICO (read-only)
1. `mcp get_contact` (cache) Marca → `custom_fields: []`. Cache SQLite con `synced_at` ANTERIOR a la creación → stale, no concluir aún ([[positive_descuadre_stale_cache]]).
2. `search_contacts` por fragmento de teléfono en sucursales de Morelos (777 = Cuernavaca) → ambos en **Temixco** (`yjqKxoO02rsdwdJZSPmD`) con 6-8 CFs completos.
3. Comparación de nombres de contact CFs Temixco vs Marca vs Tampico (tabla `object_schemas`): **idénticos**. → **DESCARTA** la hipótesis de name-mismatch del workflow [1604] (el análisis estático del jsCode sugería divergencia de nombres; es FALSO).
4. GET en VIVO (no cache) de los 4 contactos vía `ghl_client._request('GET', f'/contacts/{id}', token)`:
- Marca (ambos): `customFields=0`, `dateAdded == dateUpdated` (nunca actualizados).
- Temixco (ambos): 8 CFs; `dateUpdated` ~4h después de creación (= workflow [2004] que añadió CANAL DE ORIGEN + TIENDA).
5. Ejecuciones n8n del workflow Sucursal→Marca (`x4DqZ5FtSc43tdzB`) consultadas, pero el set reciente no cubre la ventana de creación real (2026-05-29 ~22:40/22:48 **UTC**); no se obtuvo el log del momento exacto.
## CAUSA RAÍZ
1. **Confirmada:** la réplica Sucursal→Marca creó el contacto en Marca (39-40 s después del de sucursal) **sin copiar ningún custom field**, y no hubo UPDATE posterior.
2. **NO es** divergencia de nombres de esquema (verificado idénticos).
3. **Probable (confianza media):** carrera de tiempo — el flujo leyó el contacto origen antes de que los CFs del formulario estuvieran disponibles. Es intermitente (Edgar/Tampico sí funcionó por el mismo path). Confirmación pendiente requiere log de ejecución n8n del momento de creación.
## ACCIÓN
Backfill manual de 8 CFs Temixco→Marca (mapeo por nombre → `object_schemas` de Marca), confirmado por el owner.
- `run_id=4b26f163-f87c-48c5-be53-462b2e6f53da` (script_audit, reversible). Script inline (no archivo).
- Patrón: `gc._request('PUT', f'/contacts/{cid}', mtok, json={'customFields':[{'id':marca_field_id,'field_value':val}, ...]})`.
- Orden: piloto Cristhian → verificación live (8 CFs) → Hugo.
## VERIFICACIÓN
Antes: ambos Marca `customFields=0`. Después (GET live): ambos `customFields=8` con valores correctos
(Año 2026/2011, Marca ITALIKA/mercedes benz, Versión DM 200/c280, Modalidad Tradicional (Resguardo),
Fuente SUCURSAL/PROSPECCIÓN, Sucursal "Temixco, Morelos", CANAL DE ORIGEN SUCURSAL, TIENDA TEMIXCO).
## EDGE-CASES / TRAMPAS
- **No confiar en la cache SQLite** para concluir CFs vacíos: `synced_at` puede ser anterior a la creación del contacto. Verificar SIEMPRE en vivo antes de backfillear.
- El análisis estático del jsCode del workflow puede mentir: dio "name-mismatch" como causa, falso. Verificar nombres reales en `object_schemas`.
- PUT contacto usa clave `field_value` (no `value`) y mergea CFs (no reemplaza).
- Edgar (Tampico) se omitió a pedido del owner: ya tenía datos completos (control de que el path SÍ funciona a veces).
## REUTILIZABLE
```python
# Nombre de CF -> field_id de una location (tabla object_schemas)
import sqlite3, paths
conn=sqlite3.connect(paths.DB_PATH); conn.row_factory=sqlite3.Row
nameid={}
for r in conn.execute("SELECT field_id,field_name FROM object_schemas WHERE location_id=? AND object_key='contact'",(loc,)):
nameid.setdefault(r['field_name'], r['field_id'])
# GET live + PUT CFs
import sync_engine; from sync_engine import ghl_client as gc
tok={a['location_id']:a for a in sync_engine.parse_accounts_csv()}[loc]['token']
gc._request('PUT', f'/contacts/{cid}', tok, json={'customFields':[{'id':fid,'field_value':v}]})
```
## PENDIENTES
- **Causa raíz n8n al 100%:** revisar ejecuciones de `x4DqZ5FtSc43tdzB` en la ventana 2026-05-29 ~22:40/22:48 UTC
(paginar más atrás con `/api/v1/executions?workflowId=...&includeData=true`) para confirmar carrera vs gap de lógica.
- **Sistémico:** si es carrera, hay riesgo de más contactos Temixco (u otras sucursales) con CFs vacíos en Marca.
Considerar un audit que liste contactos Marca con `source='Sincronización Sucursal'` y `customFields=0` para barrido masivo.
## ENLACES
- Memorias: [[sucursal_to_marca_cf_drop_on_create]], [[positive_descuadre_stale_cache]], [[n8n_realtime_replication]], [[n8n_2004_canal_origen_complemento]]
- Workflow Sucursal→Marca: `x4DqZ5FtSc43tdzB`; helper `scripts/n8n_workflow_lib.py`
- Caso relacionado: [2026-05-29-n8n-2004-canal-origen-tiempo-real.md](2026-05-29-n8n-2004-canal-origen-tiempo-real.md)
@@ -0,0 +1,63 @@
---
id: CASE-2026-05-29-corrector-baserow-verificador
fecha: 2026-05-29
categoria: config_location
location_ids: ["DB63 Baserow tablas 749/750", "WLPVTRxg7W074dfzBxZL (85956 PLAZA EL SALADO)", "nF1uEaYB3mCK5em9bPn2 (Eugenia, E2E)", "pMPs9M4RaGJvWwfIFVIo (Grand Plaza Toluca, creada)"]
run_ids: ["baserow PATCH/POST 749+750 (8 acciones)"]
snapshots: ["generated/migrations/baserow_mesa_control_fix_749_20260529_224430.json", "generated/migrations/baserow_verificador_fix_750_20260529_224430.json"]
status: resuelto
memorias: ["baserow_api_y_corrector", "n8n_2004_canal_origen_complemento", "super_script_fix_branch_user_origin", "erandi_intermediaria_mp"]
playbooks: []
---
## TRIGGERS
- `Baserow`, `bsrow.consultoriae3.com`, `token-auth JWT`, `tabla 749`, `tabla 750`, `SC BUCEFALO`
- `Verificador desactualizado`, `n8n no encuentra sucursal`, `match webhook 749 750`
- `PLAZA EL SALADO 85932 85956`, `La Viga 58932`, `Eugenia MP -Eugenia espacio`, `REYNOSA 85958 85970`
- `corrector baserow`, `cuentas_oficiales.csv`, `baserow_client.py`, `fix_baserow_verificador.py`
- `pendientes Erandi SUCURSAL`
## SÍNTOMA
El workflow n8n [2004] no llena sucursal/tienda (ni dispara el complemento de Canal de Origen) para varias sucursales porque el Verificador en Baserow está desactualizado. Confirmado en vivo: Eugenia se cortaba antes del PUT.
## DIAGNÓSTICO
- Acceso a la API de Baserow (read-only) con email/password → JWT. DB 63 "Bucefalo": tabla 749 (Mesa de control: Nombre/Location_ID/API_token) y 750 (Verificador: SUCURSAL/TIENDA/SC BUCEFALO/ID LOCATION BUCEFALO/SC TOKEN BUCEFALO).
- El match del workflow es `webhook location.name → 749.Nombre (7235) → 750.SC BUCEFALO (7247)`. Inspección de ejecuciones n8n (includeData) mostró: para Eugenia el Verificador 750 devolvía 0 filas porque `750.SC BUCEFALO="85974 - MP -Eugenia"` (sin espacio) ≠ `749.Nombre="85974 - MP - Eugenia"`.
- Cruce con la **lista oficial** de cuentas (que el owner proveyó como `n8n/cuentas_oficiales.csv`, name↔location_id) reveló: 1 nombre malo en 749 (PLAZA EL SALADO con `85932`, real `85956`), 5 nombres mal en 750, 1 fila ausente (Grand Plaza Toluca), SUCURSAL vacíos. NOTA: usar la lista oficial, NO `parse_accounts_csv` (su `resolve_location_name` metía ruido, p.ej. doble espacio en Marina Nacional que no existía en GHL).
## CAUSA RAÍZ
Baserow 749/750 con nombres desalineados del nombre real de la cuenta (typos de número, espacios, mayúsculas) → el match exacto del workflow falla y corta el flujo. Filas oficiales ausentes en 750.
## ACCIÓN
Corrector `scripts/fix_baserow_verificador.py` (dry-run→apply, backup previo de 749 y 750). Fuente de verdad: `n8n/cuentas_oficiales.csv` para el nombre; Verificador CSV local para SUCURSAL/TIENDA. 8 acciones aplicadas 2026-05-29 (0 errores):
- PATCH 749.Nombre PLAZA EL SALADO `85932``85956`.
- PATCH 750.SC BUCEFALO: La Viga `58932``85932`, PLAZA EL SALADO `85932``85956`, TAPACHULA `85963-``85963 -`, REYNOSA `85958``85970`, Eugenia espacio.
- PATCH 750.SUCURSAL METEPEC ← `Metepec, Estado de México` (CSV).
- POST 750 fila nueva Grand Plaza Toluca.
Filas 750 NO oficiales (Morelia 3 `rET7...`, segundo Grand Plaza `Xqpdy12...`) NO se tocaron. Cliente reusable `scripts/baserow_client.py`; auditoría `scripts/audit_baserow_verificador.py`.
## VERIFICACIÓN
- Re-auditoría: `749.Nombre != oficial = 0`, `750.SC BUCEFALO != oficial = 0`, ausentes = 0.
- E2E Eugenia: ensuciar canal de un WEB_USER → disparar webhook `8d574598` → en t+5s canal=SUCURSAL, sucursal='Narvarte Oriente, Ciudad de México', tienda='EUGENIA'. El [2004] ahora llega al PUT y el complemento corre.
## EDGE-CASES / TRAMPAS
- NO usar `parse_accounts_csv`/`resolve_location_name` como fuente de nombres (ruido: doble espacios). Usar la lista oficial provista.
- El nombre debe coincidir EXACTO en los 3 puntos (webhook, 749, 750); un espacio o dígito rompe el match.
- SUCURSAL/TIENDA sin fuente NO se inventan → lista para Erandi.
## REUTILIZABLE
```python
from scripts.baserow_client import BaserowClient
c = BaserowClient.from_credentials()
rows = c.list_rows(750) # user_field_names
c.update_row(750, row_id, {"SC BUCEFALO": "..."}, dry_run=True)
```
## PENDIENTES
- **Erandi:** completar en Baserow 750 el `SUCURSAL` de SENDERO, Independencia, Isidro Fabela, Grand Plaza Toluca (+`TIENDA` de Grand Plaza Toluca). Lista: `generated/reports/baserow_pendientes_erandi.json`.
- Decidir si las 2 filas 750 no oficiales (Morelia 3, segundo Grand Plaza) deben borrarse o si esas cuentas se dan de alta oficialmente.
## ENLACES
- Scripts: `scripts/baserow_client.py`, `scripts/audit_baserow_verificador.py`, `scripts/fix_baserow_verificador.py`. Fuente: `n8n/cuentas_oficiales.csv`.
- Backups: `generated/migrations/baserow_*_20260529_224430.json`. Lista Erandi: `generated/reports/baserow_pendientes_erandi.json`.
- Memorias: [[baserow_api_y_corrector]], [[n8n_2004_canal_origen_complemento]], [[erandi_intermediaria_mp]].
@@ -0,0 +1,153 @@
---
id: CASE-2026-05-29-descuadre-opp-multiempeno
fecha: 2026-05-29
categoria: descuadre, config_location, duplicado
location_ids:
- GbKkBpCmKu2QmloKFHy3 # Marca (Monte Providencia)
- jE41bVhhnb5T505BFm4F # 85964 - MP - Morelia 1 (Salvador)
- nF1uEaYB3mCK5em9bPn2 # 85974 - MP - Eugenia (ZONYA/LUIS origen)
- yjqKxoO02rsdwdJZSPmD # 85950 - MP - Temixco (Frankenstein Miguel Angel)
run_ids:
- descuadre_opp_del_20260529_190910 # DELETE ZONYA Wo4MXw
- descuadre_opp_del_20260529_191318 # DELETE LUIS Fv4dLJ
- a37d23ffe6574e0eb2ee8433bce2e1f3 # PUT allowDuplicateOpportunity=true en Marca
- descuadre_create_salvador_* # CREATE opp WON Salvador
snapshots:
- generated/migrations/descuadre_opp_20260529_snapshot.json # las 2 opps borradas + las que se conservan
- generated/migrations/enable_dup_opp_GbKkBpCmKu2QmloKFHy3_20260529_193342.json # settings antes del PUT
- generated/migrations/create_missing_branch_opps_20260529_193836.json # create Salvador
status: resuelto (5 mislinks pendientes de re-enlace)
memorias:
- "[[positive_opp_descuadre_double_replica]]"
- "[[opp_multiplicity_replication_gap]]"
- "[[duplicate_resolution_rules]]"
- "[[create_duplicate_phone_contact_marca]]"
playbooks:
- docs/PLAYBOOK_DESCUADRE.md
---
## TRIGGERS
- `descuadre +1 opp` / `diferencia oportunidades` / dashboard "Marca > sucursales" en opps
- `400 "Can not create duplicate opportunity for the contact"` al crear/replicar una opp
- `allowDuplicateOpportunity` (flag de settings de location)
- multi-empeño "no se replica a Marca" / contacto con 2 opps en sucursal y 1 en Marca
- `link muerto` / `ID Oportunidad Sucursal` apunta a opp borrada (GET da `400 "Opportunity doesn't exist or is deleted"`)
- réplica obsoleta que el n8n no borró tras rotar el id nativo de una opp
- token de agencia / `GHL_AGENCY_TOKEN` / 401 en `PUT /locations`
## SÍNTOMA
Dashboard Comparativa: **+1 en oportunidades** (Marca **1340** vs suma sucursales **1339**). Contactos también +1 (benigno, aparte). Signo **positivo** ⇒ sospechar cache viejo, huérfanos en Marca o doble réplica (ver [[positive_descuadre_stale_cache]]).
## DIAGNÓSTICO
**1. Audit base (read-only, vuelca a archivo):**
```powershell
python scripts\audit_brand_vs_branches_totals.py --json | Out-File -Encoding utf8 generated\agent\runs\descuadre_audit.json
```
Reveló: `diff.opportunities=1`, **`opportunities_in_branch_not_in_brand=0`** (¡nada falta según el audit!), `intra_brand_duplicates=0`. → El +1 es una opp de Marca "de más" que el audit no marca, porque su matching por-contacto la da por replicada.
**2. Matching 1:1 estricto por link** (clave para descuadre POSITIVO — el conteo del dashboard es por FILAS, no por matching):
```python
# agrupar opps de Marca por su CF "ID Oportunidad Sucursal" (resolve_opp_link_field_id)
# 1340 opps Marca = 1334 con link a opp de sucursal existente + 6 con link muerto/vacío
# los 1334 apuntan a solo 1333 opps distintas -> 1 link COMPARTIDO por 2 opps de Marca = +1
```
**3. Atribución EQUIVOCADA #1 (descartada):** el link compartido era `kGda02` (opp MIGUEL ANGEL de Eugenia), reclamado por 2 opps de Marca (`1A3P5b` $80,200 y `1l0S9v` $0). Parecía "doble réplica de Eugenia". **Falso:** al verificar en vivo, eran **dos personas distintas** llamadas Miguel Angel (tel …4949 Temixco vs …4950 Eugenia), cada una con 1 opp en su sucursal y 1 en Marca → **balanceado en filas**. El link compartido era un mislink (Frankenstein), NO la causa del +1.
**4. Matching robusto por phone-de-contacto** (porque los links estaban podridos):
```python
# para cada opp de sucursal, buscar opp de Marca con MISMO phone de contacto (consumir 1:1)
# las opps de Marca sin pareja = sobrantes reales; las de sucursal sin pareja = faltantes
```
Con phone real salieron: sobran **ZONYA `Wo4MXw` ($45k)** y **LUIS `Fv4dLJ` ($0)**; falta **Salvador (+524431452883)**.
**5. Atribución EQUIVOCADA #2 (descartada):** "falta Salvador". **Falso:** Salvador SÍ tiene opp en Marca (`NW09og`). El matching falló porque su `id_contacto_sucursal` estaba podrido.
**6. Verificación en vivo (la que cerró el caso)** — contar opps por contacto y estado de cada link:
```python
# por contacto en Marca: cuántas opps y a qué apunta su "ID Oportunidad Sucursal"
# ZONYA contacto wbUhES: 2 opps -> hrZq7j($60k lost, link OK a Eugenia IO969JW) + Wo4MXw($45k open, link P84gFZ MUERTO)
# LUIS contacto dMAc8A: 2 opps -> ezqhFc($40k lost, link OK s1fA9Wt) + Fv4dLJ($0 open, link 0l0ya7 MUERTO)
# Salvador contacto fpVvOAo: 1 opp NW09og(open, link a emsgo1) ; en Morelia tiene 2 (emsgo1 open + OWGU1u won) -> falta la WON
```
Confirmado en vivo que `P84gFZ` y `0l0ya7` dan `400 "Opportunity doesn't exist or is deleted"` en Eugenia → **links muertos**: la opp de Eugenia se borró+recreó (id rotó), el n8n creó la réplica nueva (`hrZq7j`/`ezqhFc`, ambas `createdAt` hoy) pero **no borró la vieja** → 2 opps obsoletas en Marca.
**Aritmética final del +1:** `+2` (ZONYA+LUIS obsoletas) `1` (Salvador WON faltante, multi-empeño) `= +1`.
## CAUSA RAÍZ
1. **2 réplicas obsoletas en Marca** (ZONYA `Wo4MXw`, LUIS `Fv4dLJ`): el n8n de sync de opps no borra la réplica vieja cuando el id nativo de la opp de sucursal **rota** (borrado+recreación). Quedan con `id_oportunidad_sucursal` apuntando a una opp ya inexistente.
2. **Faltante estructural de multi-empeño** (Salvador): Marca tenía `settings.allowDuplicateOpportunity = false` → GHL rechaza la 2ª opp de un contacto con `400 "Can not create duplicate opportunity for the contact"`. Por eso el n8n solo replica la 1ª opp por contacto. Las sucursales ya tenían el flag en `true`.
## ACCIÓN
1. **DELETE obsoletas** (piloto→1→1, con snapshot + audit). `ghl_client.delete_opportunity` + `script_audit.record_change(object_type='opportunity', field='__deleted__', old=<opp completa>)`. Runs `descuadre_opp_del_20260529_190910` (ZONYA), `…_191318` (LUIS). Tras ZONYA el contador ya fue +1→0; tras LUIS →−1 (destapó el faltante).
2. **Activar el flag** con el **token de agencia** (`GHL_AGENCY_TOKEN` en `.env`; el token PIT por-location da 401 en `PUT /locations`):
```bash
python scripts/enable_duplicate_opportunity.py --apply --json
# PUT /locations/{Marca} body {"settings":{"allowDuplicateOpportunity":true}} (solo ese flag; GHL hace merge)
# headers REQUIEREN User-Agent (sin él, 403). run a37d23ffe6574e0eb2ee8433bce2e1f3. false->true verificado.
```
3. **Crear la opp WON de Salvador** (multi-empeño, ya no da 400):
```bash
python scripts/create_missing_branch_opps_in_marca.py --apply --yes \
--location jE41bVhhnb5T505BFm4F --only-opp OWGU1uPoWvITmwOLIyvq --run-id descuadre_create_salvador_<ts>
```
## VERIFICACIÓN
- Conteo opps Marca: 1340 → (ZONYA) 1339 → (LUIS) 1338 → (+Salvador WON) **1339** = sucursales 1339.
- Audit final: **`diff.opportunities == 0`**.
```python
import json; d=json.load(open('generated/agent/runs/descuadre_audit_final.json',encoding='utf-8-sig'))
print(d['totals']['diff']['opportunities']) # 0
```
- Salvador en Marca: `[('won',15000),('open',15000)]` (espejo de Morelia).
- Réplicas buenas intactas: ZONYA `hrZq7j` $60k lost, LUIS `ezqhFc` $40k lost.
## EDGE-CASES / TRAMPAS
- **El conteo del dashboard es por FILAS, no por matching.** `opportunities_in_branch_not_in_brand=0` puede convivir con un descuadre real (el audit considera "replicada" una opp de sucursal si su contacto tiene CUALQUIER opp en Marca). Cazar el positivo con matching 1:1 estricto por link.
- **Link MUERTO ≠ link vacío ≠ opp ausente.** `create_missing_branch_opps_in_marca.py --all-branches` marcó **6 CREATE**, pero **solo Salvador (`multi=True`) era real**. Las otras 5 (Gerardo, Ernesto, Patricia, Lizeth, Temixco) tenían **1 opp en Marca con link muerto** → crearlas habría hecho **5 DUPLICADOS** (lección Maria/`HR99`). **Regla:** antes de aplicar el barrido, por cada candidata verificar *cuántas opps tiene el contacto en Marca* y el *estado del link* (`VACÍO`/`MUERTO`/`a-otra`=Frankenstein). Si ya tiene réplica ⇒ es RELINK, no CREATE.
- **"El contador en 0 puede ocultar basura."** Antes de tocar nada el descuadre era +1, pero escondía 2 opps fantasma (+2) y un faltante real (1). Defecto numérico ≠ defecto de integridad.
- **El flag `allowDuplicateOpportunity` no quedó activo permanentemente** tras una operación previa (volvió a `false`). Si alguien lo apaga, la replicación n8n de multi-empeños vuelve a fallar con 400.
- **`PUT /locations` necesita token de AGENCIA** (`locations.write`) + header `User-Agent`. El PIT por-location da 401.
- **Identidad podrida engaña al matching:** homónimos con tel casi igual (…4949 vs …4950) parecen duplicados pero son personas distintas; `id_contacto_sucursal` puede estar podrido. Verificar SIEMPRE en vivo por id antes de mutar.
## REUTILIZABLE
```python
# --- Aislar el sobrante real en un descuadre POSITIVO de opps (matching 1:1 estricto por link) ---
import sqlite3; from paths import DB_PATH; import scripts.audit_brand_vs_branches_totals as A
conn=sqlite3.connect(str(DB_PATH)); conn.row_factory=sqlite3.Row
BRAND=A.BRAND_LOCATION_ID; blink=A.resolve_opp_link_field_id(conn,BRAND)
brand=A.load_opps(conn,BRAND)
branch_ids=set()
for r in conn.execute("SELECT location_id FROM accounts"):
if r['location_id'] not in (BRAND,'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'):
branch_ids|={o['id'] for o in A.load_opps(conn,r['location_id'])}
used=set()
for o in brand:
lv=A.extract_opp_link_value(o.get('custom_fields_json'),blink)
if lv and lv in branch_ids and lv not in used: used.add(lv)
else: print('SOBRANTE/mislink:',o['id'],o['name'],lv) # link vacío, muerto o duplicado
# --- Verificar/activar el flag (dry-run sin --apply) ---
# python scripts/enable_duplicate_opportunity.py # dry-run
# python scripts/enable_duplicate_opportunity.py --apply # activa (token agencia en .env)
# python scripts/enable_duplicate_opportunity.py --disable --apply # rollback del flag
# python scripts/check_allowDuplicate_settings.py # verificación read-only multi-cuenta
# --- Estado de un link en vivo (muerto?) ---
# GET /opportunities/{id} con Version 2021-07-28 + User-Agent -> 400 "doesn't exist or is deleted" = MUERTO
```
## PENDIENTES
**5 mislinks (calidad de dato, NO afectan el conteo)** — réplicas en Marca con `id_oportunidad_sucursal` muerto/mal; re-enlazar (PUT del CF) sin crear duplicados:
- Gerardo (`Bj2bIN` → debe apuntar a Morelia `x3AXkY`)
- Ernesto (`UNtCRNQ``5kDn6b`)
- Patricia (`OGQtfmjF``zzBzWC`)
- Lizeth (`j0iKZo``LGSPKo`)
- **Miguel Angel Temixco (`1A3P5b`)**: linkea a Eugenia `kGda02` (Frankenstein) y trae valor $80,200 en vez de $56,671 (Temixco `OQBrOQN9`) → re-enlazar **y** corregir valor.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[opp_multiplicity_replication_gap]], [[duplicate_resolution_rules]], [[create_duplicate_phone_contact_marca]], [[positive_descuadre_stale_cache]]
- Playbook: [docs/PLAYBOOK_DESCUADRE.md](../PLAYBOOK_DESCUADRE.md)
- Scripts: [scripts/enable_duplicate_opportunity.py](../../scripts/enable_duplicate_opportunity.py), [scripts/create_missing_branch_opps_in_marca.py](../../scripts/create_missing_branch_opps_in_marca.py) (flag `--all-branches`), [scripts/audit_brand_vs_branches_totals.py](../../scripts/audit_brand_vs_branches_totals.py), [scripts/check_allowDuplicate_settings.py](../../scripts/check_allowDuplicate_settings.py)
- Artefactos: snapshots en `generated/migrations/` (ver frontmatter); audits en `generated/agent/runs/descuadre_audit_*.json`
@@ -0,0 +1,69 @@
---
id: CASE-2026-05-29-n8n-2004-canal-origen-tiempo-real
fecha: 2026-05-29
categoria: cascada_n8n
location_ids: ["todas las sucursales (workflow n8n compartido)", "nF1uEaYB3mCK5em9bPn2 (85974 - MP - Eugenia, gap Baserow)", "uJEn2iuUficuml9zxAnt (85976 - MP - Cancún, E2E ok)"]
run_ids: ["n8n workflow ddUEORBEtZLzsQF2 versionId 6e9a405c→069558e3"]
snapshots: ["n8n/backup_canal_origen_ddUEORBEtZLzsQF2_20260529_215207.json"]
status: resuelto
memorias: ["n8n_2004_canal_origen_complemento", "super_script_fix_branch_user_origin", "createdby_only_in_individual_get", "erandi_intermediaria_mp", "feedback_dry_run_protocol"]
playbooks: []
---
## TRIGGERS
- `[2004]`, `ddUEORBEtZLzsQF2`, `webhook 8d574598`, `actualizar contact.sucursal contact.tienda`
- `Canal de Origen tiempo real`, `complemento n8n canal origen`
- `Verificador 750 devuelve 0`, `Baserow 750 no encuentra sucursal`, `flujo se corta antes del PUT`
- `Eugenia no está en Baserow 750`, `sucursal renombrada Baserow desincronizado`
- `createdBy distinto por ejecución`, `esUsuario IF n8n`
- `n8n executions API includeData`, `verify_post versionId`
## SÍNTOMA
El batch `fix_branch_user_origin.py` corrige el backlog, pero los contactos NUEVOS creados por usuario en sucursal siguen naciendo sin Canal de Origen. Se pide complementar el workflow en tiempo real [2004] (corre al crear contacto) para que ponga Canal de Origen=SUCURSAL solo a los creados por usuario.
## DIAGNÓSTICO
1. Estructura del [2004] (GET read-only vía `n8n_workflow_lib`): flujo lineal Webhook→Datos de Lead→Omitir @ezcorp→Baserow 749 (cuentas)→Datos API→GET /contacts/{id}→GET /locations/{id}/customFields→Code (resuelve sucursal/tienda por fieldKey)→Baserow 750 (Verificador)→PUT /contacts/{id} (sucursal+tienda). Nodo huérfano `...SUCURSAL1` (POST opportunities/search) sin entrada = código muerto.
2. `GET /contacts/{id}` SÍ devuelve `createdBy.source` (ver [[createdby_only_in_individual_get]]).
3. fieldKey de "CANAL DE ORIGEN" = `contact.fuente_de_posible_cliente` (heredado), consistente en 4 sucursales muestreadas, picklist incluye SUCURSAL. Resolver por nombre con fallback a fieldKey.
## CAUSA RAÍZ
1. (gap a cerrar) Ni el [2004] ni el workflow nativo de GHL escribían Canal de Origen del contacto.
2. (hallazgo) **Baserow tabla 750 (Verificador) desincronizado:** el [2004] busca por `location.name` (field 7247); si la sucursal no está con su nombre actual, devuelve 0 filas y **el flujo se corta ANTES del PUT** (sucursal/tienda y canal). Confirmado en Eugenia "85974 - MP - Eugenia" (renombrada, ver [[erandi_intermediaria_mp]]). El batch usa el Verificador CSV LOCAL (sí la tiene) → no se notaba.
## ACCIÓN
Edición del workflow [2004] vía `scripts/n8n_workflow_lib.py` con `n8n/_add_canal_origen_branch.py` (idempotente, backup→dry-run→apply→verify):
- Extiende el Code node: resuelve `canal` (por fieldKey/nombre) y expone `createdBySource`/`esUsuario` leyendo `$('Obtener Contacto Cuenta Origen - SUCURSAL').item.json.contact.createdBy.source`.
- Añade tras el PUT sucursal: IF "Creado por usuario" → [true] PUT Canal de Origen=SUCURSAL → Tag+ sucursal → Tag- formulario → Tag- facebook-ads (DELETEs con `onError: continueRegularOutput`). [false]=fin.
- NO toca Fuente de Prospecto. Workflow desactivado/reactivado para el PUT estructural. versionId 6e9a405c→069558e3, 14→19 nodos.
## VERIFICACIÓN
E2E disparando el webhook de producción (`https://webhookn8.consultoriae3.com/webhook/8d574598-...`) con `{contact_id,email,location:{name}}` + inspección de ejecuciones vía `GET /api/v1/executions/{id}?includeData=true`:
- Eugenia (85974): Verificador 750 devolvió **0 filas** → flujo cortado antes del PUT → Code esUsuario=True correcto pero IF no corre. (Confirma el gap Baserow, no falla del cambio.)
- **Cancún (85976, sí está en 750):** contacto WEB_USER ensuciado a FORMULARIO → webhook → en t+5s canal=**SUCURSAL**, tags=['sucursal']. ✓
- INTEGRATION (solana, Eugenia): Code esUsuario=False → no toca canal (FACEBOOK intacto). ✓
- JUAN CARLOS (ensuciado en Eugenia para la 1ª prueba) restaurado manualmente a SUCURSAL.
## EDGE-CASES / TRAMPAS
- Dos webhooks casi simultáneos → execs consecutivas (52760/52761); confirmar `body.contact_id` por ejecución antes de concluir.
- El mismo contact_id devuelve `createdBy` correcto con el token de la sucursal; el "INTEGRATION" que vi al inicio era de la ejecución del OTRO contacto (confusión de execs), no del token.
- Probar el E2E SOLO en sucursales presentes en Baserow 750 (Cancún sí; Eugenia no).
- DELETE de tags inexistentes: usar `onError: continueRegularOutput` para no romper el flujo.
## REUTILIZABLE
```python
# Inspeccionar ejecuciones n8n por nodo (qué corrió, outputs):
from scripts.n8n_workflow_lib import load_credentials, N8NClient
c=N8NClient(*load_credentials())
st,data=c._request('GET','/api/v1/executions/<EXID>?includeData=true')
rd=data['data']['resultData']['runData'] # {nodeName: [{data:{main:[[items]]}}]}
```
## PENDIENTES
- [HECHO 2026-05-29] **Baserow 749/750 corregido** con el corrector automático — ver [[CASE-2026-05-29-corrector-baserow-verificador]] / [[baserow_api_y_corrector]]. Eugenia y los demás nombres ya alinean; E2E OK. Solo queda que Erandi complete SUCURSAL/TIENDA sin fuente (4 sucursales) — `generated/reports/baserow_pendientes_erandi.json`.
- [HECHO] Agendado: Tarea Windows "MP Origen Check" (diaria 07:00) corre el dry-run y deja alerta en `generated/runtime/origen_check_alert.json`; el owner aplica desde el dashboard. Ver [[origen_check_agendado]].
## ENLACES
- Script mutación: `n8n/_add_canal_origen_branch.py`; lib `scripts/n8n_workflow_lib.py`.
- Batch: `scripts/fix_branch_user_origin.py`.
- Backup/rollback: `n8n/backup_canal_origen_ddUEORBEtZLzsQF2_20260529_215207.json`.
- Memorias: [[n8n_2004_canal_origen_complemento]], [[super_script_fix_branch_user_origin]], [[createdby_only_in_individual_get]], [[erandi_intermediaria_mp]].
@@ -0,0 +1,88 @@
---
id: CASE-2026-05-29-origen-sucursal-contactos-usuario
fecha: 2026-05-29
categoria: custom_field
location_ids: ["nF1uEaYB3mCK5em9bPn2 (85974 - MP - Eugenia, piloto)", "todas las sucursales productivas (47, batch); excluye Marca GbKkBpCmKu2QmloKFHy3 y demos Vf7qQl3L9vakJ8hDtQ8e / Z64WQKORPVwXb5mn68Ef"]
run_ids: ["fbuo-cc20241b7a6f (piloto Eugenia)", "fbuo-batch-8c31110b2d (batch 47 sucursales)"]
snapshots: []
status: resuelto
memorias: ["createdby_only_in_individual_get", "super_script_fix_branch_user_origin", "feedback_dry_run_protocol", "name_account_with_location_id"]
playbooks: []
---
## TRIGGERS
- `createdBy.source`, `WEB_USER`, `MOBILE_USER`, `INTEGRATION`
- `contactos creados por usuario`, `canal de origen sucursal`, `origen sucursal`
- `createdBy no viene en el listado`, `GET /contacts/ omite createdBy`
- `fix_web_user_branch_contacts roto`, `siempre detecta 0`
- `fix_branch_user_origin.py`, `super script origen sucursal`
- `Fuente de Prospecto ALIANZA`, `PROSPECCIÓN`, `no sobrescribir Fuente de Prospecto`
- `tag formulario -> sucursal`, `etiqueta de origen única`
- `Canal de Origen de la Oportunidad = Sucursal`
## SÍNTOMA
Los contactos creados a mano en una sucursal (por un empleado) no traen canal de origen confiable: el campo nativo `source` llega vacío/None y no indica "sucursal". Esto ensucia el CF `Canal de Origen` (contacto y opp) y las etiquetas de origen. Objetivo: identificar los contactos creados 100% por usuario en sucursal y dejarlos con origen = Sucursal (CF + tag), propagando a sus oportunidades. Solo sucursales (no Marca, no demos).
## DIAGNÓSTICO
Pasos read-only (todos con el helper `tag_canal_origen_workflow`):
1. Primer dry-run del super script leyendo `createdBy` del **listado**`WEB_USER a corregir: 0`. Distribución: `(vacío): 129`. Sospecha: el listado no trae `createdBy`.
2. Comparación listado vs GET individual de un contacto:
- Listado `GET /contacts/`: keys incluyen `source` (=None), `attributions`, pero **NO** `createdBy`.
- Individual `GET /contacts/{id}`: trae `createdBy = {source: 'WEB_USER', sourceName: 'EUGENIA- 85974 MP', channel: 'APP', ...}` y `attributionSource = {medium: 'manual', sessionSource: 'CRM UI'}`.
3. Muestra de 20 GETs individuales en Eugenia: 3 WEB_USER + 17 INTEGRATION → el criterio discrimina perfecto. Proxy del listado: WEB_USER ≈ `attributions[0].medium == 'manual'`.
4. Audit log oficial del CRM para `maMw3C8QmhGVChRqL36y` (JUAN CARLOS RAMIREZ): "Action: Created, Modified by: Web user" → coincide con `createdBy.source == WEB_USER`.
5. Schema de contacto Eugenia (resolución de campos por alias, correcta):
- `Canal de Origen``KLEZyRNR0jrldccerErV` (name real "CANAL DE ORIGEN")
- `Fuente de Prospecto``QN1BNTKgCzcSOHa2wSZc`
- `Sucursal``pmrGTW3tIa7oz7rQJMVx`, `TIENDA``H3g8J4NbgbcM4glyW9GZ`
6. Distribución de valores en Eugenia (SQLite): `Canal de Origen` {SUCURSAL 84, FORMULARIO 28, FACEBOOK 14, vacío 3}; `Fuente de Prospecto` {SUCURSAL 84, LEAD DIGITAL 42, **ALIANZA 2**, **PROSPECCIÓN 1**}. → `Fuente de Prospecto` contiene valores de negocio que NO deben pisarse.
## CAUSA RAÍZ
1. **`createdBy` solo viene en el GET individual** del contacto; el listado paginado lo omite (ver [[createdby_only_in_individual_get]]). El script previo `scripts/fix_web_user_branch_contacts.py` lo leía del listado → roto silenciosamente (siempre 0).
2. Los contactos creados por empleado quedan con `createdBy.source` ∈ {`WEB_USER` (UI web), `MOBILE_USER` (app móvil)}; los replicados desde Marca por n8n quedan `INTEGRATION`.
## ACCIÓN
Super script nuevo `scripts/fix_branch_user_origin.py` (registrado en SCRIPTS_METADATA como "Origen Sucursal (contactos creados por usuario)"). Ver [[super_script_fix_branch_user_origin]]. Orden contacto→opp:
- Contacto: tag único `sucursal` (quita `formulario`/`facebook-ads`), `Canal de Origen` = SUCURSAL. Si falta `Sucursal`/`TIENDA`, se completan desde el Verificador CSV (`load_verifier_map`).
- TODAS las opps del contacto: `Canal de Origen de la Oportunidad` = Sucursal + propaga `Sucursal`/`TIENDA`.
- **NO toca `Fuente de Prospecto`** (decisión del owner: preserva ALIANZA/PROSPECCIÓN). No sincroniza a Marca.
Protocolo dry-run → piloto → batch ([[feedback_dry_run_protocol]]):
```
# Dry-run (Fase 1):
python scripts/fix_branch_user_origin.py --location nF1uEaYB3mCK5em9bPn2
# Piloto:
python scripts/fix_branch_user_origin.py --location nF1uEaYB3mCK5em9bPn2 --apply --run-id fbuo-cc20241b7a6f
# Batch:
python scripts/fix_branch_user_origin.py --all --apply --run-id fbuo-batch-8c31110b2d
```
## VERIFICACIÓN
- Piloto Eugenia (run `fbuo-cc20241b7a6f`, success): 89 creados por usuario, 6 contactos, 98 opps. JUAN CARLOS post-apply en vivo: tags=['sucursal'], Canal de Origen='SUCURSAL', Sucursal='Narvarte Oriente, Ciudad de México', TIENDA='EUGENIA', **Fuente de Prospecto='ALIANZA' intacto**; su opp Tpd964ztTwgNf1ipL5NC con Canal de Origen de la Oportunidad='Sucursal' + Sucursal propagado.
- Consistencia Sucursal: 126/129 contactos ya tenían 'Narvarte Oriente, Ciudad de México'; los 3 vacíos quedaron con el MISMO valor del Verificador. Sin divergencia.
- Batch (run `fbuo-batch-8c31110b2d`, success): 359 creados por usuario detectados en 47 sucursales, 48 contactos + 273 opps corregidos, 0 errores. Auditoría: 110 cambios contact + 284 opp, todos `applied`.
## EDGE-CASES / TRAMPAS
- **No leer `createdBy` del listado** → siempre 0. Hay que GET individual por contacto (costoso pero fiel; el dashboard paraleliza por sucursal).
- **No sobrescribir `Fuente de Prospecto`**: contiene ALIANZA/PROSPECCIÓN (valores de negocio), no solo SUCURSAL/LEAD DIGITAL.
- Incluir **MOBILE_USER** además de WEB_USER (ambos = creación manual por empleado).
- 8 sucursales tenían **0 contactos** (no tocadas) y 5 tienen **Verificador con Sucursal vacía** + 2 **no están en el Verificador**: si más adelante reciben contactos creados por usuario sin Sucursal, no se autocompletará hasta corregir el Verificador.
## REUTILIZABLE
```python
# createdBy SOLO en GET individual:
full = ghl_request("GET", f"/contacts/{cid}", token); inner = full.get("contact") or full
src = (inner.get("createdBy") or {}).get("source") # WEB_USER | MOBILE_USER | INTEGRATION
```
## PENDIENTES
- Corregir el Verificador para las sucursales con Sucursal vacía / ausentes (Isidro Fabela, SENDERO, Grand Plaza, Independencia, Morelia 3, + las 2 ausentes) por si reciben contactos creados por usuario.
- Confirmar si las 8 sucursales con 0 contactos es esperado (sucursales nuevas) o falta sync/acceso.
- Identificación de origen Facebook Ads / formulario en sucursal (fuera de alcance de este caso).
## ENLACES
- Memorias: [[createdby_only_in_individual_get]], [[super_script_fix_branch_user_origin]], [[feedback_dry_run_protocol]], [[name_account_with_location_id]]
- Scripts: `scripts/fix_branch_user_origin.py`, helpers de `scripts/tag_canal_origen_workflow.py`, `scripts/fill_sucursal_tienda_from_location.py` (`load_verifier_map`)
- Roto/superado: `scripts/fix_web_user_branch_contacts.py`
- Logs: `generated/logs/fbuo_batch_8c31110b2d.log`
@@ -0,0 +1,79 @@
---
id: CASE-2026-05-29-tienda-vacia-formulario-sitio-web
fecha: 2026-05-29
categoria: custom_field | cascada_n8n | config_location
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "rQYjjwsGnjEGagskOxix (85930 TULYEHUALCO)", "nRSeOhlhQ3vyirTKYhPi (85961 VILLAS DEL SOL)", "blRZ21GlzgUCA7bl2uVw (85975 Querétaro)", "R34lUVVpltnB8Z1RqnEB (85971 Satélite)", "uZnMH5bO6MXTHcgHeyq9 (85935 Pilares)"]
run_ids: ["26217ad9-934f-40d5-af69-bd0cbb5c02e4 (backfill TIENDA)", "bb27026c-1d99-458d-be1d-d34b7498b1a4 (delete Guco)"]
snapshots: []
status: parcial
memorias: [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]], [[sucursal_to_marca_cf_drop_on_create]], [[name_account_with_location_id]]
playbooks: [docs/PLAYBOOK_DESCUADRE.md]
---
## TRIGGERS
- `TIENDA vacía`, `campo TIENDA empty`, `lead sin tienda`, `Formulario - Sitio Web TIENDA`
- `Baserow 750 más de un resultado`, `Si hay más de un resultado`, `SUCURSAL contains ambiguo`
- `Toluca Estado de México 3 filas`, `Metepec 3 filas`, `750 filas duplicadas SUCURSAL`
- `createdBy INTEGRATION OAUTH`, `source Formulario - Sitio Web`, `CANAL DE ORIGEN FORMULARIO`
- contactos: `Juan Carlos espiritu`, `Adrian Garza`, `Gerardo Juárez`, `Luis Fernando mejía`, `Jesús Niño`, `Jorge Erick hernandez`, `Miguel Velasquez`, `Jorge Enrique ibarra`, `Guco Aseram`
## SÍNTOMA
Export de Marca: 9 leads `source="Formulario - Sitio Web"` con campo **TIENDA vacío**. Hipótesis inicial del owner: "creados antes de la optimización n8n". FALSA (ver causa).
## DIAGNÓSTICO (read-only)
1. GET live (no cache) de los 9 en Marca → TIENDA vacía confirmada; 8 traen `ID Contacto Sucursal`.
2. Ubicación de la sucursal por `ID Contacto Sucursal` (tabla `contacts` cache) + GET live → **TIENDA vacía TAMBIÉN en la sucursal** (no es gap de replicación; nunca se asignó en origen).
3. `createdBy.source=INTEGRATION` / `Formulario - Sitio Web` → ni [2004] ni `fix_branch_user_origin.py` los cubren (esos = WEB_USER/MOBILE_USER).
4. Conteo global: de **175** contactos `Formulario - Sitio Web`, solo **16 (9%)** con TIENDA vacía = estos 8 × 2 (Marca+sucursal). El 91% sí la tiene → NO sistémico por source.
5. TIENDA canónica por sucursal (consenso 85-119 contactos): se deriva del **branch físico**, no del texto `Sucursal`. (`object_schemas` + `contacts.custom_fields_json`.)
6. Workflow Marca→Sucursal V2 `4UMRwxJdHFfOGHBp` (webhook formulario): nodo Baserow `Obtener Info de cuenta objetivo - SUCURSAL` filtra **tabla 750, field 7240 (SUCURSAL), operador `contains` = `$json.Contacto.Sucursal`**, y hay IF **`Si hay más de un resultado`**.
7. Test directo Baserow 750 (`baserow_client.BaserowClient.from_credentials`, `list_rows(750)`):
- `Toluca, Estado de México`**3 filas** (SC `85935 - MP - Pilares`, TIENDA GRAND PLAZA / ISIDRO FABELA / INDEPENDENCIA).
- `Metepec, Estado de México`**3 filas** (Pilares/PILARES + 2× METEPEC/METEPEC dup).
- Tulyehualco / Playa del Carmen / Querétaro / Satélite → **1 fila** (resuelven bien).
## CAUSA RAÍZ (doble)
1. **Determinística (Luis Fernando=Toluca, Jorge Enrique=Metepec):** Baserow 750 tiene filas **ambiguas/duplicadas** donde el mismo `SUCURSAL` mapea a múltiples TIENDA → el filtro `contains` devuelve >1 → IF `Si hay más de un resultado` → el flujo no asigna TIENDA. **Recurrirá** en todo lead Toluca/Metepec hasta limpiar 750.
2. **Transitoria (los otros 6, match único en 750):** debían resolver; TIENDA vacía = fallo puntual de ejecución. Confirmación 100% requeriría logs n8n del momento (probablemente no retenidos).
- **DESCARTADO:** "pre-optimización" (7 de 8 son 2026-05-29, post-fix 05-28) y "name-mismatch de esquema".
## ACCIÓN
1. Backfill TIENDA (sucursal + Marca), valor = TIENDA canónica del branch físico, `run_id=26217ad9-934f-40d5-af69-bd0cbb5c02e4`:
TULYEHUALCO (Juan Carlos, Jorge Erick), VILLAS DEL SOL (Adrian), QUERETARO (Gerardo, Miguel), SATELITE (Jesús), PILARES (Luis Fernando, Jorge Enrique). 16 PUTs, piloto Juan Carlos → verificado → lote.
- Nota: a Luis Fernando/Jorge Enrique se les puso **PILARES** (consenso de su branch real), que es más correcto que las filas rotas de Baserow.
2. Delete contacto huérfano **Guco Aseram** `HEb1qBGilEReVITtq0GZ` (Marca; sucursal Villahermosa ya no existe, sin contraparte). `run_id=bb27026c-1d99-458d-be1d-d34b7498b1a4`. DELETE → `succeeded:true`; GET posterior → HTTP 400 (gone).
## VERIFICACIÓN
- Backfill: GET live de los 16 → TIENDA = valor esperado (16/16 OK).
- Delete: GET `/contacts/HEb1qBGilEReVITtq0GZ` → HTTP 400.
- Pendiente re-sync de las 5 sucursales + Marca para que la cache refleje (la verdad viva ya es correcta).
## EDGE-CASES / TRAMPAS
- TIENDA se deriva del **branch físico**, NO del texto libre `Sucursal` (Luis Fernando: Sucursal="Toluca" pero branch=Pilares → TIENDA=PILARES).
- Verificar SIEMPRE en vivo: la cache puede no reflejar asignaciones recientes.
- Baserow 750: el filtro del workflow es `contains` sobre `SUCURSAL` (field 7240), distinto del match por `SC BUCEFALO` (7247) que usa el corrector. Las filas ambiguas rompen este path aunque el corrector de nombres esté OK.
## REUTILIZABLE
```python
# Test de ambigüedad Baserow 750 por SUCURSAL (lo que ve el workflow)
from scripts.baserow_client import BaserowClient
c=BaserowClient.from_credentials(); rows=c.list_rows(750)
hits=[r for r in rows if r.get('SUCURSAL') and 'Toluca, Estado de México'.lower() in str(r['SUCURSAL']).lower()]
# >1 hit => el lead de esa Sucursal NO recibirá TIENDA
```
```python
# TIENDA canónica de un branch (consenso de sus contactos)
from collections import Counter; import json
# parse contacts.custom_fields_json, contar valores del field_id de TIENDA del branch
```
## PENDIENTES
- **Limpiar Baserow 750:** deduplicar / desambiguar filas con mismo `SUCURSAL` y distinta TIENDA (Toluca→3, Metepec→3). Mientras existan, los leads Toluca/Metepec seguirán sin TIENDA. Evaluar extender el corrector (`fix_baserow_verificador.py`) o añadir desambiguación en el workflow (`Si hay más de un resultado`).
- **6 casos transitorios:** si recurren, revisar ejecuciones de `4UMRwxJdHFfOGHBp` en la ventana de creación.
- **Sucursal text de Luis Fernando** ("Toluca") inconsistente con su branch (Pilares/Metepec). No tocado; reportado.
- Re-sync de las 5 sucursales + Marca.
## ENLACES
- Memorias: [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]], [[sucursal_to_marca_cf_drop_on_create]]
- Workflow: `4UMRwxJdHFfOGHBp` (Marca→Sucursal V2); helper `scripts/n8n_workflow_lib.py`, `scripts/baserow_client.py`
- Caso relacionado: [2026-05-29-corrector-baserow-verificador.md](2026-05-29-corrector-baserow-verificador.md)
@@ -0,0 +1,100 @@
---
id: CASE-2026-05-30-comparativa-auditoria-completa-buckets
fecha: 2026-05-30
categoria: descuadre | cascada_n8n | config_location
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)", "uZnMH5bO6MXTHcgHeyq9 (85935 - MP - Pilares, hub)", "NSDniGzjxotVDNa5YxqW (85937 - MP - METEPEC, shell)"]
run_ids: ["45554292-1b2d-491c-8107-b0ebf81c0b86 (cleanup Patricia)", "fill-temixco-20260530", "bf-cristhian-20260530", "bf-hugo-20260530", "isai-tienda-20260530", "isai-opp-20260530", "fix-identity-collisions-20260530 (sarahi name + miguel TIENDA)", "fix-sarahi-tienda-20260530", "cleanup link 9i1rDQa (isai dup rpDH)", "create-miguel-brand-opp-20260530"]
snapshots: ["generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_122326.json", "generated/migrations/fix_identity_collisions_20260530_130329.json", "generated/migrations/create_miguel_brand_opp_20260530_132718.json"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[verificador_tipo_de_tienda_colapso]]", "[[positive_descuadre_stale_cache]]", "[[audit_hub_map_metepec_pilares]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md"]
---
## TRIGGERS
- `Comparativa Marca vs Sucursales sigo viendo discrepancia`
- `auditar por completo los buckets`, `optimizar toda la sección Comparativa`
- `opps +1 descuadre positivo`, `opportunities_in_brand_duplicate_link 2`
- `contacts_in_brand_present_in_other_branch_not_assigned 84 falsos positivos`
- `TIENDA=METEPEC vive en Pilares 85935`, `Metepec 85937 vacío hub digital`
- `audit lee Verificador CSV no Baserow`, `falta fila METEPEC→85935 digital`
- `DIGITAL_HUB_BY_SHELL`, `hub-map en código audit_brand_vs_branches_totals`
- `PATRICIA PARRA NAVARRO zzBzWC4adBrdTA8WhQph`, `réplica abandoned $0 updatedAt==createdAt`
## SÍNTOMA
Comparativa del dashboard: contactos cuadraban (diff 0) pero **opps +1** (Marca 1341 vs sucursales 1340). El usuario reportó discrepancia persistente y pidió auditar TODOS los buckets + optimizar la sección. Cache fresco (sync 2026-05-30 11:1811:19).
## DIAGNÓSTICO
1. Frescura de cache: `sync_logs` → todas sincronizaron 11:1811:19. No es stale.
2. Audit completo a JSON (read-only):
```
python scripts/audit_brand_vs_branches_totals.py --json > generated/agent/runs/descuadre_audit_20260530.json
# OJO: el redirect en Windows escribe cp1252, no utf-8. Leer con .decode('cp1252').
```
3. Buckets con items: `opportunities_in_brand_duplicate_link=2`, `present_in_other_branch_not_assigned=84`, `contacts_missing_id_field=4`, `without_tienda=2`, `in_branch_not_in_brand=1`, `not_in_any_branch=1`, `opportunities_missing_id_field=1`. Resto 0.
4. **El +1 de opps** = bucket `opportunities_in_brand_duplicate_link`: 2 opps de Marca con el MISMO link `zzBzWC4adBrdTA8WhQph` (Morelia 1). `extra_opps=1`.
5. **El bucket 84** descompuesto por (expected→actual): **82/84 = TIENDA=METEPEC esperado 85937, actual Pilares 85935.** Verificador CSV mapea METEPEC→85937 (vacío, físico), pero los leads digitales viven en el hub Pilares. Las otras tiendas del cluster (GRAND PLAZA/ISIDRO/SENDERO) SÍ tienen fila digital →85935; METEPEC no. → falsos positivos.
6. Los contactos cuadraban a 0 **enmascarando** 2 problemas que se cancelan: `in_branch_not_in_brand=1` (sarahi sarabia, real) + `not_in_any_branch=1` (test21 harness, prueba).
## CAUSA RAÍZ
1. **+1 opps**: réplica duplicada en Marca (n8n `Cfgwp0bOtDW8zuKW` hizo CREATE en vez de UPDATE por carrera de indexado). La sobrante: `sAxBY01AQNwSr0OExQof` *abandoned* $0 created==updated 2026-05-30 03:02, colgada de contacto distinto (edgar morales). Mismo patrón que [[positive_opp_descuadre_double_replica]].
2. **84 ruido**: el audit lee el Verificador CSV, que NO codifica la consolidación de hub (Toluca/Metepec/Lerma→Pilares 85935) que Baserow 750 sí tiene ([[verificador_tipo_de_tienda_colapso]]). Falta la fila digital METEPEC→85935.
## ACCIÓN
**A) Optimización del audit (código, sin tocar Bucéfalo)** — autorizada "hub-map en código":
- En `scripts/audit_brand_vs_branches_totals.py`: constante `DIGITAL_HUB_BY_SHELL` (shell loc → hub Pilares `uZnMH5bO6MXTHcgHeyq9`) + check en el bucket `present_in_other_branch`: si la sucursal asignada es shell de un hub y el contacto está en el hub, se considera bien asignado.
- Resultado: bucket 84 → **2** (los 2 reales: luis fernando tienda=PILARES quirk; miguel angel EUGENIA→Temixco).
**B) Corrección +1 opps** — autorizada "borrar opp duplicada":
```
# dry-run (verifica en vivo, snapshot):
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link zzBzWC4adBrdTA8WhQph
# apply:
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --only-link zzBzWC4adBrdTA8WhQph
# run_id=45554292-1b2d-491c-8107-b0ebf81c0b86 borró sAxBY01AQNwSr0OExQof (conservó OGQtfmjFk31M5eXKDBpO open $70k)
# re-sync Marca para refrescar cache:
python -c "import sync_engine as se; a=next(x for x in se.parse_accounts_csv() if x['location_id']=='GbKkBpCmKu2QmloKFHy3'); se.sync_account(a['location_id'], a['token'])"
```
**C) Pendientes (2ª tanda, misma sesión)** — todos aplicados con dry-run+snapshot+audit:
- `fill_contact_id_sucursal.py --location Temixco --apply`: cristhian/hugo lado sucursal.
- `backfill_brand_contact_id_sucursal.py --only-contact <U9DWipe|bUNqMZaL> --apply`: cristhian/hugo lado Marca.
- `fix_brand_tienda_from_sucursal.py --only-contact JV9g9tWO --apply`: isai TIENDA→ECATEPEC (reporta "Errores:1" cosmético al persistir local; el PUT a GHL sí aplica, verificado en vivo).
- `backfill_opp_sucursal_link.py --only-opp 0eYLJ6 --apply`: enlazó la opp huérfana de isai → **destapó** que isai tenía 2 réplicas en Marca (rpDH+0eYLJ) de 1 opp en Ecatepec.
- `cleanup_brand_duplicate_replica_opps.py --only-link 9i1rDQa --apply`: borró rpDH (réplica nueva), conservó 0eYLJ (original, más antigua). → destapó el faltante 1.
- **sarahi** (colisión de teléfono con "luis enrique suchil"): el contacto Marca m6QK tenía email+opp de sarahi pero nombre de luis → UPDATE firstName/lastName→"Sarahí Sarabia" (NO sync, evita 3er duplicado) + TIENDA ATLACOMULCO→ATIZAPAN. jBaK (luis real) intacto.
- **miguel** (TIENDA EUGENIA→TEMIXCO) + el faltante 1 era su empeño físico Temixco OQBrOQN9 ($56,671) sin réplica. NO es multi-empeño: son 2 opps DISTINTAS (empeño físico ene-08 $56,671 vs lead digital Marca abr-24 $80,200 sin link, CF vacíos). Se **creó** réplica en Marca (8HITkGkOn3gN23Tl8LBr, con link a OQBrOQN9) SIN tocar la digital — script ad-hoc reusando `resolve_brand_pipeline_and_stage`+`create_opportunity`; el link se setea con PUT separado (el POST no guarda customFields). Gotcha: tras el PUT el GET inmediato da link=None (latencia indexado GHL); re-sync confirma válido.
- **luis fernando** (TIENDA=PILARES, vive en Pilares): se agregó 85940 Isidro Fabela (0 contactos) a `DIGITAL_HUB_BY_SHELL` → resuelto.
## VERIFICACIÓN
| | Inicial | Tras 1ª tanda | Final |
|---|---|---|---|
| Contactos diff | 0 | 0 | **0** |
| Opps diff | **+1** | 0 | **0** (1340=1340) |
| `opportunities_in_brand_duplicate_link` | 2 | 0 | 0 |
| `present_in_other_branch_not_assigned` | 84 | 2 | **0** |
| `contacts_missing_id_field` | 4 | 4 | **0** |
| `opportunities_missing_id_field` | 1 | 1 | **0** |
| `contacts_in_branch_not_in_brand` | 1 | 1 | **0** |
| Items accionables totales | ~95 | — | **2 (solo test21, fantasma)** |
> Lección clave: el "diff 0" inicial de opps era engañoso — enmascaraba el duplicado de isai (+1) contra el faltante de miguel (1). El faltante NO aparecía en `opportunities_in_branch_not_in_brand` (=0) porque el bucket matchea por contacto y miguel ya tenía una opp en Marca (gap multi-opp). Se cazó con un diff 1:1 de links sucursal↔Marca (parsear `fieldValueString`, no `value`).
## EDGE-CASES / TRAMPAS
- El redirect `> file.json` en Windows NO escribe utf-8; el JSON sale cp1252. Decodificar con `cp1252`.
- `sync_account(location_id, token)` — son DOS args posicionales, no el dict de cuenta.
- El audit lee SQLite (cache). Tras un DELETE en GHL hay que **re-sync de la location** antes de re-auditar o el número no cambia.
- El hub-map mapea varios shells→Pilares pero solo METEPEC generaba falsos positivos (los demás ya resolvían al hub vía CSV o tienen 0 contactos). Mapearlos todos es inocuo y a prueba de cambios de orden en el CSV.
## REUTILIZABLE
- Descomponer cualquier bucket grande por (expected_branch → actual_branch) con Counter antes de concluir "error de datos": muchas veces es mapeo del Verificador, no datos.
- `cleanup_brand_duplicate_replica_opps.py --only-link <link>` es el camino seguro para el descuadre positivo de opps por doble réplica (verifica en vivo + snapshot + script_audit).
## PENDIENTES
- **test21** (fantasma de índice): GET 400 / search 200. No accionable; se auto-corrige cuando GHL reconcilie el índice. Sigue en `without_tienda` + `not_in_any_branch`.
- **Lead digital de miguel** (opp Marca 1A3P5b $80,200, sin link): es un lead digital creado directo en Marca que nunca bajó a su sucursal (Temixco). Idealmente lo baja la cascada n8n Marca→Sucursal. Queda solo-Marca; el conteo cuadra igual. Revisar si ese lead digital es válido y debe cascar.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[verificador_tipo_de_tienda_colapso]], [[positive_descuadre_stale_cache]], [[name_account_with_location_id]], [[audit_hub_map_metepec_pilares]]
- Playbook: docs/PLAYBOOK_DESCUADRE.md
- Scripts: scripts/audit_brand_vs_branches_totals.py, scripts/cleanup_brand_duplicate_replica_opps.py
- Caso previo relacionado (réplica duplicada, quedó parcial): docs/casos/2026-05-30-descuadre-opp-replica-duplicada-marca.md
@@ -0,0 +1,87 @@
---
id: CASE-2026-05-30-descuadre-opp-deadlink
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Monte Providencia / Marca)", "jE41bVhhnb5T505BFm4F (85964 - MP - Morelia 1)", "2eJPAdEGjC7iPhDDAeoy (85977 - MP - Interlomas)"]
run_ids: ["99365455-dee6-4f1f-b52a-9076683e02bb"]
snapshots: ["generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[positive_descuadre_stale_cache]]", "[[ghl_opportunity_search_quirks]]", "[[duplicate_resolution_rules]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md", "docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---
## TRIGGERS
- `DIFERENCIA OPORTUNIDADES +2` / descuadre positivo de opps Marca > Sucursales
- audit con **todos los buckets de opps en 0** (`opportunities_in_brand_duplicate_link: 0`, `opportunities_in_branch_not_in_brand: 0`, `opportunities_missing_id_field: 0`) pero `diff.opportunities` ≠ 0
- opp de Marca con "ID Oportunidad Sucursal" poblado pero `GET /opportunities/{link}` → HTTP **400** `"Opportunity doesn't exist or is deleted."`
- "link muerto" / dead-link / réplica obsoleta que el n8n no borró al rotar el id de la opp de sucursal
- contacto con 2 opps en Marca (una link válido, otra link muerto) y 1 sola opp viva en sucursal
## SÍNTOMA
Dashboard Comparativa: Marca 1,341 opps / Sucursales (suma) 1,339 → **+2 descuadre detectado**. Contactos cuadrados (0).
## DIAGNÓSTICO
1. **Frescura de caché** (sospechoso #1 de positivo): `sync_logs` → "Sincronizar Todo" recién corrido 2026-05-30 10:09; Marca synced 10:09:13 con 1341 opps. Caché FRESCO → +2 real, no stale. (Los 3 "running syncs" del metrics eran zombies del 2026-05-20, ignorar.)
2. **Audit completo** (`scripts/audit_brand_vs_branches_totals.py --json`): diff opps +2 pero **TODOS los buckets de opps = 0**. Ningún bucket lo explica → caso fuera de la cobertura del audit.
3. **Matching 1:1 robusto manual**: agrupar opps de Marca por su link (`extract_opp_link_value`, field `j029pu3OU02ATNccJR6l`) vs set de ids nativos de las 47 sucursales (excl. demos). Resultado: **4 opps de Marca con link MUERTO** (apunta a id que no está en ninguna sucursal); 0 sin link; 0 link duplicado; 1337 con link válido.
4. **Triage de las 4** (por contacto): 2 contactos (Ernesto, Gerardo) tienen una **2ª opp en Marca con link VÁLIDO** a la opp viva → la de link muerto sobra (+2). Los otros 2 (Patricia, Lizeth) tienen **1 sola opp** mal enlazada → 1:1, no inflan conteo (el audit las empareja vía contacto, por eso reportó 0 faltantes).
5. **Verificación EN VIVO**: `GET /opportunities/{deadlink}` → HTTP 400 "doesn't exist or is deleted" en los 4. `GET /opportunities/search?contact_id=` → cada contacto de sucursal tiene exactamente 1 opp viva (coincide con caché).
**Callejón descartado:** los 2 scripts de cleanup NO sirven aquí. `cleanup_brand_duplicate_replica_opps.py` agrupa por link COMPARTIDO (aquí cada link muerto es único). `cleanup_brand_orphan_opportunities.py` empareja por NOMBRE → ve la réplica obsoleta como "sincronizada" (mismo nombre que la opp viva) y no la toca.
## CAUSA RAÍZ
Réplicas **obsoletas** en Marca que el workflow n8n de sync de opps (`Cfgwp0bOtDW8zuKW`) dejó atrás cuando el id nativo de la opp de sucursal **rotó** (la opp original se borró/recreó en la sucursal). La réplica vieja quedó con el `ID Oportunidad Sucursal` apuntando a un id ya inexistente; el n8n creó una nueva réplica con el id nuevo en vez de actualizar la vieja → **doble réplica**. Patrón idéntico a [[positive_opp_descuadre_double_replica]].
## ACCIÓN
Confirmación explícita del usuario (borrar 2 + re-enlazar 2). Snapshot live previo en `generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json`. Todo bajo `run_id=99365455-dee6-4f1f-b52a-9076683e02bb` en `script_audit` (reversible desde dashboard).
- **DELETE** (réplicas obsoletas en Marca): `UNtCRNQHqLf4Vv4vdY39` (Ernesto Chavez, open $10k) + `Bj2bINlklDAoLmTsyg3r` (Gerardo Padilla, open $50k). `gc.delete_opportunity`.
- **RELINK** (`ID Oportunidad Sucursal` → id vivo, `gc.update_opportunity` customFields): `OGQtfmjFk31M5eXKDBpO` (Patricia) `RNwRPgWEpi0nWIBYKbeZ``zzBzWC4adBrdTA8WhQph`; `j0iKZoeeYb1wNOaWHwNN` (Lizeth) `wYLZJd4Xpj0K9HYyikWX``LGSPKoeeEQWEq39HpPLi`.
## VERIFICACIÓN
- Borradas: `GET` → HTTP 400 ambas. Re-enlaces: `GET` confirma el link nuevo en ambas.
- Re-sync Marca: `sync_account` → 1339 opps (antes 1341).
- Re-audit: **diff opps = 0** (1339 = 1339), contactos = 0. Descuadre resuelto.
## EDGE-CASES / TRAMPAS
- GHL devuelve **400 (no 404)** para opp borrada. Tratar 400 como "no existe".
- El campo link sale bajo clave `fieldValue` en `GET /opportunities/{id}` (no `value`). Ver [[ghl_opportunity_search_quirks]].
- No confundir este caso con stale cache: aquí el caché estaba fresco. Verificar SIEMPRE frescura primero igual.
- Patricia/Lizeth NO se borran (son la única réplica 1:1); borrarlas habría creado un faltante 2. Distinguir "sobra" (contacto tiene otra opp con link válido) de "mal enlazada" (única opp).
## REUTILIZABLE
Snippet de detección de dead-link replicas (no lo cubre ningún bucket del audit):
```python
import sqlite3, json, paths
BRAND='GbKkBpCmKu2QmloKFHy3'; DEMOS={'Vf7qQl3L9vakJ8hDtQ8e','Z64WQKORPVwXb5mn68Ef'}
c=sqlite3.connect(str(paths.DB_PATH)); c.row_factory=sqlite3.Row
LINK='j029pu3OU02ATNccJR6l' # field_id opportunity.id_oportunidad_sucursal en Marca
def extract(cf,fid):
if not cf: return None
for f in (json.loads(cf) or []):
if isinstance(f,dict) and f.get('id')==fid: return f.get('value') or f.get('fieldValue')
branch_ids={r['id'] for r in c.execute("SELECT id FROM opportunities WHERE location_id NOT IN (?,?,?)",(BRAND,*DEMOS))}
for o in c.execute("SELECT id,name,contact_id,custom_fields_json FROM opportunities WHERE location_id=?",(BRAND,)):
lv=(extract(o['custom_fields_json'],LINK) or '').strip()
if lv and lv not in branch_ids: print('DEAD-LINK', o['id'], o['name'], '->', lv)
```
Verificación live de un dead-link: `GET https://services.leadconnectorhq.com/opportunities/{id}` con header `Version: 2021-07-28` → 400 = borrada.
## CAUSA DE FONDO (atacada 2026-05-30)
Investigación del workflow `Cfgwp0bOtDW8zuKW` (vía API n8n) reveló que **NO se puede arreglar en el workflow**: el trigger es un Webhook de creación/actualización de opp (payload con datos de vehículo/fuente); **GHL NO dispara evento de borrado de opp**. Por tanto una cascada de borrado en tiempo real (borrar la réplica de Marca cuando la opp de sucursal se borra) es **inviable** — no hay disparador. Además `Decidir Match` ya está correcto para opps vivas (Baserow global tabla 754 → fallback contacto → CREATE). Gap secundario detectado: `Crear Oportunidad - MARCA` y `Actualizar Oportunidad - MARCA (v2)` no tienen salida → no hacen upsert a Baserow en tiempo real (frescura solo por backfill cada 30 min); no es la causa del dead-link.
**Solución desplegada = reconciliador determinista periódico** (backstop que converge dead-links a 0 sin importar cómo surjan):
- `scripts/reconcile_brand_deadlink_opps.py`: detección cache (link no en set de ids de sucursal) → verificación EN VIVO (GET 400) → `classify()` puro (DELETE réplica obsoleta / RELINK id rotado / SKIP ambiguo o cache stale) → snapshot + script_audit. Dry-run default. Registrado en `SCRIPTS_METADATA` (dashboard, mutator).
- `scripts/scheduled_deadlink_check.py` + `run_deadlink_check.bat` + Tarea Programada Windows **"MP Deadlink Check"** (diaria 7:15am): corre dry-run con `--resync-first` y deja `generated/runtime/deadlink_check_alert.json` si hay accionables; el owner aplica desde el dashboard (protocolo dry-run).
- **Validación 100%:** self-test 8/8 (`--self-test`, lógica de decisión); E2E RELINK en vivo (corromper link→fake, detect→GET400→relink al id vivo, auto-reversible); E2E DELETE en vivo (opp desechable creada+borrada); dry-run real = 0; descuadre global = 0.
## GAP SECUNDARIO CERRADO (2026-05-30)
Cableado el **upsert a Baserow en tiempo real** tras CREATE/UPDATE en `Cfgwp0bOtDW8zuKW` (sus salidas estaban vacías). `n8n/_add_baserow_opp_upsert.py --apply` agregó 2 nodos (`Preparar Upsert Mapeo` + `Crear Mapeo - Baserow`), diseño create-only condicional con `onError=continueRegularOutput` (no puede romper la replicación). Cierra la ventana de 30 min del backfill: una opp recién replicada queda mapeada al instante → una re-ejecución hace UPDATE, no CREATE duplicado. **Validado E2E live** replicando el webhook real (exec 52872 crea la fila; exec 52873 re-dispara → match Baserow → no duplica). Ver [[n8n_opp_idempotency_baserow_mapping]].
## PENDIENTES
- Considerar agregar al audit un bucket "dead-link" (link de Marca no presente en ningún id nativo de sucursal) para que la Comparativa lo reporte sin correr el reconciliador.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[positive_descuadre_stale_cache]], [[ghl_opportunity_search_quirks]], [[duplicate_resolution_rules]], [[n8n_opp_sync_match]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/audit_brand_vs_branches_totals.py, scripts/backfill_opp_sucursal_link.py (patrón PUT relink)
- Snapshot: generated/migrations/descuadre_opp_deadlink_20260530_snapshot.json
@@ -0,0 +1,85 @@
---
id: CASE-2026-05-30-descuadre-opp-replica-duplicada-marca
fecha: 2026-05-30
categoria: descuadre
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca - Monte Providencia)"]
run_ids: ["cdo-pilot-180d299cdd (piloto ANSELMO, 1 borrado)", "cdo-batch-13ce0ae8c0 (lote, 7 borrados)"]
snapshots: ["generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092546.json (piloto)", "generated/migrations/cleanup_brand_duplicate_replica_opps_20260530_092637.json (lote)"]
status: resuelto
memorias: ["[[positive_opp_descuadre_double_replica]]", "[[n8n_opp_sync_match]]", "[[matching_rules]]", "[[form_submissions_source_of_truth]]", "[[duplicate_resolution_rules]]"]
playbooks: ["docs/PLAYBOOK_DESCUADRE.md", "docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md"]
---
## TRIGGERS
- `DIFERENCIA OPORTUNIDADES +10` / descuadre positivo Marca > Sucursales
- `réplicas duplicadas en Marca` / `mismo ID Oportunidad Sucursal` / dos opps de Marca apuntan a la misma opp de sucursal
- bucket `opportunities_in_brand_duplicate_link`
- workflow n8n `Cfgwp0bOtDW8zuKW` ("Sincronizar Oportunidad - Nodos Nuevos (Create/Update)") hace CREATE en vez de UPDATE
- nodo `Decidir Match (Create vs Update)`, `Buscar Oportunidades del Contacto - MARCA`
- `cleanup_brand_orphan_opportunities.py` reporta CERO huérfanas pero el descuadre sigue positivo
- caso antelmo↔anselmo / mismo teléfono distinto contacto
## SÍNTOMA
Dashboard 2026-05-30: OPORTUNIDADES MARCA 1349 vs SUCURSALES (suma) 1339 → **DIFERENCIA +10** (descuadre positivo). Contactos cuadrados (0).
## DIAGNÓSTICO
1. `python scripts/cleanup_brand_orphan_opportunities.py`**0 huérfanas**; 1345/1349 opps protegidas por el link "ID Oportunidad Sucursal". Si todas tienen contraparte y aun así Marca>Sucursales, la única explicación es **varias opps de Marca apuntando al MISMO id de sucursal**.
2. Query de unicidad del link (extract_opp_link_value + OPP_ID_PATTERN sobre `opportunities` location Marca, agrupando por valor): **8 valores de link con 2 opps de Marca cada uno → 8 opps sobrantes**. Confirmado.
3. `createdAt` en vivo (el cache SQLite NO guarda fecha — `opportunities.date_added` es NULL): cada par = 1 original (creado 2026-04-24..05-22) + 1 duplicado **creado en ráfaga 2026-05-29 ~20:4821:14 hora local (02:4803:14 UTC del 05-30)**, con `updated==created` (intactos).
4. n8n `Cfgwp0bOtDW8zuKW`: el nodo `Decidir Match (Create vs Update)` busca opps **acotado al contacto de Marca resuelto** (`/opportunities/search?contact_id=<Set Contact ID Resuelto>`) y compara el CF "ID Oportunidad Sucursal" (`fieldValueString ?? fieldValue`) contra el id de la opp de sucursal. Sin match → CREATE (sin fallback por nombre, intencional).
5. **Evidencia decisiva**: para 2 pares, `keep.contactId != delete.contactId`. La búsqueda del nodo por el contacto del KEEP SÍ devuelve el CF (bajo `fieldValueString`) → el código de match está bien. El fallo es que original y duplicado cuelgan de **contactos de Marca distintos**:
- ANSELMO: `lGfjbkKEB25jittwcKLd` "ANSELMO SANCHEZ" (tel +525523396616, temo6715@gmail.com, 05-20) vs `KEo4p3e5OvWAvosnYrtT` "Antelmo López Rodríguez" (**mismo tel**, cuauhplayer@gmail.com, 05-27).
- MIGUEL: `RwxMQr0Ywvydjr3veCYo` "Miguel Angel" (tel +527775114949, ene-08) vs `hE9U9Q62Xgd0wPeq6L80` "MIGUEL ANGEL" (**tel distinto, sin email**, 05-28).
## CAUSA RAÍZ
Una **misma opp de sucursal** se replicó a Marca **dos veces, cada vez sobre un contacto de Marca diferente**, porque la identidad del contacto en Marca es ambigua (mismo teléfono con nombre/email variante; o teléfono distinto sin email). El nodo `Decidir Match` evalúa la llave de idempotencia (id de opp de sucursal) **solo dentro del contacto resuelto ese run**, no de forma global en Marca. Al resolver un contacto distinto al que tiene la opp original, no la encuentra y hace CREATE → réplica duplicada. (No es un bug del lector de CF; es scope del match + duplicidad/ambigüedad de contacto.)
## ACCIÓN
- **Detección (read-only, aplicada):** nuevo bucket `opportunities_in_brand_duplicate_link` en `scripts/audit_brand_vs_branches_totals.py` (agrupa opps de Marca por valor de link; >1 = duplicado; recomienda keep/delete por jerarquía). Expuesto en dashboard (tarjeta + export CSV `bucket=brand_duplicate_link_opps`).
- **Limpieza (mutador, dry-run validado, PENDIENTE de aplicar):** `scripts/cleanup_brand_duplicate_replica_opps.py`. Detecta clusters vía audit, trae cada opp en vivo, conserva la canónica (jerarquía: valor → status → createdAt más antiguo) y borra las sobrantes. Snapshot en `generated/migrations/` + `script_audit` (reversible por run_id). Endpoint `POST /api/comparativa/cleanup-duplicate-opps` + botón "Limpiar duplicados" + registrado en `script_runner.py`.
- Dry-run: 8 clusters / 8 a borrar; en los 8 conserva el original antiguo y borra el duplicado del 2026-05-30. Comando: `python scripts/cleanup_brand_duplicate_replica_opps.py` (dry-run) → `--apply --run-id <uuid>`.
- **Raíz n8n (PENDIENTE de confirmación del owner):** endurecer `Cfgwp0bOtDW8zuKW` para que la idempotencia no dependa del contacto resuelto. Ver PENDIENTES.
## VERIFICACIÓN
- Antes: bucket `opportunities_in_brand_duplicate_link` = 16 items / 8 grupos / 8 sobrantes; `diff.opportunities` = +10; brand opps = 1349.
- **Después (CONFIRMADO 2026-05-30):** piloto borró 1 (ANSELMO `prRKgLINCgclX9V3O6R0`, verificado: GET da 400 "deleted", la canónica `yjiU8pjCkohiPpJGZlH6` permanece). Lote borró 7 (CESAR, JOSE LUIS ARQ, SANTIAGO FLORES, "d", Alfonso Mendoza, MARIA DE LOS ANGELES, MIGUEL ANGEL). Re-sync de Marca → **brand opps 1349 → 1341 (8)**, bucket duplicados = **0**, `diff.opportunities` = **+10 → +2**.
- El **+2 residual** es estructural: opps de Marca cuyo origen vive en cuenta demo/excluida de la suma filtrada (1339), NO duplicados (bucket=0 y orphan-check=0). Benigno; documentar si se desea cero absoluto.
- Comando de verificación: `python scripts/audit_brand_vs_branches_totals.py --json` y leer `missing.opportunities_in_brand_duplicate_link`.
- GHL responde **400** (no 404) al GET de una opp borrada → `fetch_opp_live` trata 400/404/"deleted"/"doesn't exist" como inexistente (fix tras el 1er intento de lote que crasheó en fase de planificación, sin borrar nada).
## EDGE-CASES / TRAMPAS
- `cleanup_brand_orphan_opportunities.py` NO ve este problema: trata el link como salvaguarda y nunca verifica unicidad. No concluir "todo limpio" con ese script en un descuadre positivo.
- `opportunities.date_added` es NULL en el cache → para fechar "cuándo surgió" hay que ir en vivo (`GET /opportunities/{id}``createdAt`). El limpiador desempata por createdAt en vivo, no por el cache.
- Al borrar la opp duplicada queda un **contacto de Marca posiblemente huérfano de opp** (p.ej. "Antelmo López"). Es un problema de contacto aparte (ver `delete_intra_brand_duplicates.py` + [[matching_rules]]); el limpiador de opps NO lo toca.
- Phone solo nunca es match ([[matching_rules]]): ANSELMO/Antelmo comparten tel pero son contactos distintos en Marca; no fusionar a ciegas.
## REUTILIZABLE
```bash
# Detectar opps de Marca con link duplicado (read-only):
python scripts/audit_brand_vs_branches_totals.py --json # -> missing.opportunities_in_brand_duplicate_link
# Limpiar (dry-run -> piloto -> lote):
python scripts/cleanup_brand_duplicate_replica_opps.py
python scripts/cleanup_brand_duplicate_replica_opps.py --only-link <id_opp_sucursal> --apply --run-id <uuid>
python scripts/cleanup_brand_duplicate_replica_opps.py --apply --run-id <uuid>
# Inspeccionar workflow n8n de sync de opps:
python -c "import sys;sys.path.insert(0,'scripts');import n8n_workflow_lib as l;c=l.N8NClient(*l.load_credentials());import json;print(json.dumps(c.get_workflow('Cfgwp0bOtDW8zuKW')['nodes'],ensure_ascii=False)[:2000])"
```
## FIX PREVENTIVO n8n (APLICADO 2026-05-30) — opción (c) mapeo Baserow
Idempotencia GLOBAL por id de opp de sucursal, independiente del contacto.
- **Tabla Baserow creada por API**: DB 63, **table_id=754** "Mapeo Opp Sucursal-Marca": `id_opp_sucursal` (primario, field 7280), `id_opp_marca` (7283), `location_id_sucursal`, `updated_at`. Se extendió `scripts/baserow_client.py` con `create_table/create_field/update_field/delete_field`.
- **Backfill**: `scripts/backfill_baserow_opp_mapping.py --table-id 754 --apply` → 1341 mapeos (0 duplicados). Gotcha: el JWT de Baserow EXPIRA a mitad (~1004 creates → 401 ERROR_INVALID_ACCESS_TOKEN); re-ejecutar (idempotente, `ya_ok`) completa el resto.
- **Rewire `Cfgwp0bOtDW8zuKW`** (`n8n/_add_baserow_opp_idempotency.py --apply`; backup `n8n/backup_pre_baserow_opp_idempotency_*.json`; versionId nuevo `9caa764a-...`): nodo Baserow `Buscar Mapeo Opp - Baserow` (getAll tabla 754, filtro field 7280 == opp_id sucursal, `alwaysOutputData`+`onError=continueRegularOutput`) insertado `Set Contact ID Resuelto → [lookup] → Buscar Oportunidades del Contacto - MARCA → Decidir Match`. `Decidir Match` reescrito: si mapeo Baserow → UPDATE global; si no, fallback CF por contacto; si nada → CREATE. Degrada a la lógica previa si Baserow falla (try/catch).
- **Validación (data-layer, segura)**: lookup por link mapeado devuelve 1 fila con `id_opp_marca` correcto (ANSELMO/MIGUEL); link inexistente → 0 filas. NO se disparó webhook real (evita mutar producción); la próxima sync real ejercita el path.
- **Frescura**: Tarea Programada Windows **"MP Baserow Opp Mapping"** (cada 30 min, `run_baserow_opp_mapping.bat`) corre el backfill idempotente para mapear opps nuevas.
## PENDIENTES
1. (Opcional) Confirmar con webhook real / próxima ejecución de producción que el path UPDATE-vía-Baserow funciona E2E (riesgo bajo: degrada a lógica previa si algo falla).
2. Investigar si el evento del 2026-05-29 ~21:00 (re-disparo masivo) fue corrida manual/replay y acotarlo.
3. (Mejora) Persistir `createdAt`/`updatedAt` de GHL en `opportunities` (`db.py`) para no depender de GET en vivo al fechar.
## ENLACES
- Memorias: [[positive_opp_descuadre_double_replica]], [[n8n_opp_sync_match]], [[matching_rules]], [[form_submissions_source_of_truth]], [[duplicate_resolution_rules]], [[name_account_with_location_id]]
- Playbooks: docs/PLAYBOOK_DESCUADRE.md, docs/PLAYBOOK_ENLACE_OPORTUNIDADES.md
- Scripts: scripts/cleanup_brand_duplicate_replica_opps.py, scripts/audit_brand_vs_branches_totals.py
- Workflow: n8n Cfgwp0bOtDW8zuKW
@@ -0,0 +1,72 @@
---
id: CASE-2026-05-30-sucursal-tag-en-leads-digitales
fecha: 2026-05-30
categoria: custom_field, cascada_n8n
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "HvDw9Eg3rjrwkbQJXqfi (Marina Nacional)", "R34lUVVpltnB8Z1RqnEB (Satélite)", "KEZ7dAhgwzK4uZfMvZuj (Puebla)", "nRSeOhlhQ3vyirTKYhPi (Villas del Sol)", "nF1uEaYB3mCK5em9bPn2 (Eugenia)", "rQYjjwsGnjEGagskOxix (Tulyehualco)", "XkduzafvwsrWcEFg6Qlj (Cd. Carmen)"]
run_ids: ["pilot-sucursal-tag-20260530013645", "batch-sucursal-tag-20260530013808", "fix-miza-canal-fuente-20260530015547"]
snapshots: []
status: resuelto
memorias: ["[[createdby_only_in_individual_get]]", "[[super_script_fix_branch_user_origin]]", "[[n8n_realtime_replication]]", "[[ghl_tags_api]]", "[[custom_fields_picklist_alignment]]", "[[feedback_dry_run_protocol]]"]
playbooks: []
---
## TRIGGERS
- `tag sucursal y facebook-ads juntos`, `conflicto facebook-ads sucursal`, `falsa atribución digital`
- `createdBy.source INTEGRATION channel OAUTH`, `origen real facebook ads vs creado por sucursal`
- `quitar tag sucursal lead digital`, `audit_origin_for_contact_list.py`, `fix_remove_sucursal_tag_digital.py`
- `n8n no re-añade tag sucursal`, `tag se queda quitado tras 60s`
- `Miza Olguin Puebla`, `contacto Canal SUCURSAL pero opp FORMULARIO`, `Fuente PROSPECCIÓN no sobrescribir`
## SÍNTOMA
Export de 87 contactos de Marca con tags `facebook-ads` + `sucursal` simultáneos, `CANAL DE ORIGEN=FACEBOOK`, `Fuente de Prospecto=LEAD DIGITAL`. El usuario sospechaba que algunos eran **creados a mano por staff de sucursal** que pusieron apariencia digital falsa (tag facebook-ads + Fuente "redes sociales/LEAD DIGITAL").
## DIAGNÓSTICO
La columna `source` de SQLite guarda el `source` NATIVO ("Facebook"/"Formulario"), NO `createdBy.source` ([db.py:300](../../db.py#L300)). Y es poco confiable (un contacto de sucursal tenía `source=null` mientras Marca decía "Formulario"). `createdBy.source` SOLO viene en `GET /contacts/{id}` individual ([[createdby_only_in_individual_get]]).
Como los contactos del export son de **Marca** (réplica n8n → su createdBy siempre es INTEGRATION), el origen real está en el **contacto de la sucursal**. Resolución Marca→Sucursal por CF `id_contacto_sucursal` (id Marca `E6lI9ykWhqpj7Pmi7Qd3`) → buscar la location en SQLite (`SELECT location_id FROM contacts WHERE id=?`) → GET en vivo en esa sucursal.
Script: `python scripts/audit_origin_for_contact_list.py --csv generated/reports/origin_audit/_input_ids.csv` (read-only). Resultado: **87/87 = INTEGRATION (REAL_DIGITAL)**, 0 manuales. Verificación cruda: `createdBy = {"source":"INTEGRATION","channel":"OAUTH",...}`. El detector SÍ distingue manuales (un contacto de control de otra cuenta salió `WEB_USER`).
**Callejón descartado:** la nota de agente + Canal=SUCURSAL de Miza NO prueban creación manual; su contacto de sucursal es INTEGRATION (entró digital, un agente la reetiquetó/anotó después).
## CAUSA RAÍZ
1. Los 87 son leads de **origen digital** (FB Ads/formulario → integración). El tag `facebook-ads` y `LEAD DIGITAL` son **correctos**. La hipótesis de "fake digital" quedó **refutada**.
2. La etiqueta que sobra y genera el conflicto es **`sucursal`** (en la taxonomía MP `sucursal` y `facebook-ads` son tags de ORIGEN mutuamente excluyentes — ver `fix_branch_user_origin.py`).
3. Caso aislado: **Miza Olguin** tenía CANAL/Fuente del CONTACTO = SUCURSAL, pero su **opp** ya estaba en Canal=FORMULARIO / Fuente=PROSPECCIÓN. Su origen real es **formulario** (no facebook-ads).
## ACCIÓN
Dry-run → piloto (1 contacto) → batch, protocolo [[feedback_dry_run_protocol]].
1. **Quitar tag `sucursal`** de los 87 en **ambos lados** (Marca + sucursal), `scripts/fix_remove_sucursal_tag_digital.py` (mutador, dry-run default, bucle quitar→esperar 60s→verificar para vencer la carrera n8n):
- Piloto: `--only iR4fS0f2fOGB75vtVZMP --apply --run-id pilot-sucursal-tag-20260530013645` → Ronda 2 = 0 con tag (n8n NO lo re-añade).
- Batch: `--apply --run-id batch-sucursal-tag-20260530013808` → 172 lados limpiados, Ronda 2 = 0.
2. **Miza** (inline, `run_id fix-miza-canal-fuente-20260530015547`): contacto Canal SUCURSAL→**FORMULARIO**, Fuente SUCURSAL→**LEAD DIGITAL** en Marca + Puebla. **La opp NO se tocó** (ya correcta: FORMULARIO/PROSPECCIÓN). Cero opps creadas/modificadas.
## VERIFICACIÓN
Sweep final independiente (174 lados, GET vivo): **0 con tag `sucursal`**, **0 perdieron `facebook-ads`**. Miza verificada: Canal=FORMULARIO, Fuente=LEAD DIGITAL en ambos lados tras 60s. Resultado: cambio 100% efectivo.
## EDGE-CASES / TRAMPAS
- **No basta el `source` nativo ni la caché**: usar `createdBy.source` en vivo del contacto de **sucursal** (no de Marca).
- **`facebook-ads` era CORRECTO**: la instrucción inicial era quitar facebook-ads; los datos dijeron quitar `sucursal`. Confirmar dirección con el usuario antes de mutar evitó corromper 87 leads legítimos.
- **Opp vs contacto pueden divergir**: Miza tenía contacto=SUCURSAL pero opp=FORMULARIO. Investigar la opp ANTES de elegir el valor del contacto (puse FORMULARIO, no FACEBOOK, para no inyectar inconsistencia contacto-vs-opp).
- **No sobrescribir Fuente de opp** = PROSPECCIÓN (valor de negocio).
- **n8n NO re-añade el tag `sucursal`** a contactos INTEGRATION (piloto lo probó). Quitar un tag no crea opps → cero riesgo de duplicados de opp.
- `DELETE /contacts/{id}/tags` con body `{"tags":[...]}` (el path `/tags/{name}` da 404) — ya manejado por `remove_contact_tag` ([[ghl_tags_api]]).
## REUTILIZABLE
```bash
# Auditar origen real de una lista de contactos de Marca (read-only)
python scripts/audit_origin_for_contact_list.py --ids "ID1,ID2,..." # o --csv <ruta>
# Quitar tag sucursal de los REAL_DIGITAL (dry-run -> piloto -> batch)
python scripts/fix_remove_sucursal_tag_digital.py # dry-run
python scripts/fix_remove_sucursal_tag_digital.py --only <marca_id> --apply --run-id pilot-...
python scripts/fix_remove_sucursal_tag_digital.py --apply --run-id batch-...
```
## PENDIENTES
- Investigar **por qué** estos digitales recibieron el tag `sucursal` históricamente (qué workflow/migración lo puso). No se re-añade hoy, pero conviene cerrar la fuente para que no reaparezca en leads futuros.
## ENLACES
- Scripts: `scripts/audit_origin_for_contact_list.py`, `scripts/fix_remove_sucursal_tag_digital.py`
- Artefactos: `generated/reports/origin_audit/origin_audit_20260530_012600.csv` (+ `_input_ids.csv`)
- Memorias: [[createdby_only_in_individual_get]], [[super_script_fix_branch_user_origin]], [[n8n_realtime_replication]], [[ghl_tags_api]]
@@ -0,0 +1,80 @@
---
id: CASE-2026-05-30-verificador-tipo-de-tienda-colapso
fecha: 2026-05-30
categoria: config_location | cascada_n8n | custom_field
location_ids: ["GbKkBpCmKu2QmloKFHy3 (Marca)", "uZnMH5bO6MXTHcgHeyq9 (85935 Pilares = hub digital Toluca/Metepec/Lerma)", "NSDniGzjxotVDNa5YxqW (85937 Metepec, VACÍA)", "jE41bVhhnb5T505BFm4F (85964 Morelia 1)"]
run_ids: []
snapshots: ["generated/migrations/baserow_verificador_pre_tipo_tienda_750_20260530_005359.json", "generated/migrations/baserow_verificador_pre_restore_nodigital_suc_750_20260530_011528.json", "n8n/backup_add_tipo_filter_*_20260530_0115*.json (3 workflows)"]
status: resuelto
memorias: [[verificador_tipo_de_tienda_colapso]], [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]]
playbooks: []
---
## TRIGGERS
- `TIPO DE TIENDA`, `field 7279`, `NO DIGITAL`, `PARCIAL`, `FULL AUTOS`, `Verificador 750 columna tipo de tienda`
- `colapsar filas digitales`, `1 fila canónica por location`, `SUCURSAL contains más de un resultado`
- `Toluca 3 filas Pilares`, `hub digital Pilares`, `Metepec 85937 vacío`, `Metepec routing Pilares`
- `no-digital 0 contactos`, `premisa formulario no-digital`, `fix_baserow_tipo_de_tienda.py`, `baserow delete_row`
## SÍNTOMA
Leads "Formulario - Sitio Web" de zonas multi-tienda (Toluca/Metepec/Lerma) con TIENDA inconsistente/vacía (ver caso 2026-05-29). El owner agregó columna `TIPO DE TIENDA` a Baserow 750 y propuso que n8n excluya `NO DIGITAL`. Pidió estudiar implicaciones e iterar hasta confirmar funcionamiento en vivo.
## DIAGNÓSTICO (read-only)
1. **3 workflows** leen 750 (no 2): `ddUEORBEtZLzsQF2` [2004] (escribe `contact.tienda` por SC BUCEFALO `equal` limit 1), `4UMRwxJdHFfOGHBp` [1604] Marca→Sucursal (por SUCURSAL `contains` limit 3, toma row[0]), `EuPdIkCORyh0skoB` [SUCURSAL] (2 nodos 750). `x4DqZ5FtSc43tdzB` y `Cfgwp0bOtDW8zuKW` NO tocan 750.
2. **Premisa validada en vivo** (`get_all_contacts`): 85938/85939/85940/85941/85965 = **0 contactos**. Y **85937 Metepec = 0**. Pilares 85935 = 87 contactos, TIENDA consenso `PILARES` (85/87). Morelia 1 = 60, `MORELIA 1`.
3. **Contradicción CSV vs datos:** el CSV editado enrutaba Metepec→85937, pero 85937 está vacío; los leads de la zona caen en Pilares. Decisión del owner: **Metepec→Pilares** (modelo hub).
4. Estado live 750 (52 filas): Toluca = 3 filas →Pilares (GRAND PLAZA/ISIDRO FABELA/INDEPENDENCIA, TIPO vacío) = **ambiguo**. Metepec = 1 digital + 2 NO DIGITAL dup. Morelia/otros = OK. Fila 258 = junk (TIPO literal="TIPO DE TIENDA"). Filas NO DIGITAL de Toluca (Isidro/Grand/Indep) ya existían huérfanas (sin SUCURSAL).
## CAUSA RAÍZ
Ambigüedad estructural en Baserow 750: **varias filas por el mismo texto SUCURSAL** (sin colapsar). El filtro NO DIGITAL solo, NO basta para Toluca (las 3 filas eran todas digitales →Pilares con TIENDA distinta). Hace falta **colapsar a 1 fila canónica por location digital**.
## ACCIÓN (Baserow 750, reversible vía backup)
Script `scripts/fix_baserow_tipo_de_tienda.py` (dry-run→apply, `backup_table` previo). Se agregó `delete_row()` a `scripts/baserow_client.py`.
1. **Routing (piloto):** fila 241 Toluca→`TIENDA=PILARES, TIPO=FULL AUTOS`; **borrar** 242, 243 (dups Toluca). fila 244 Lerma→`PILARES/FULL AUTOS`. **borrar** 254 (dup Metepec) y 258 (junk).
2. **Labels (batch):** `TIPO DE TIENDA=PARCIAL` en 31 filas single-store vacías.
3. **Consistencia:** blanquear `SUCURSAL` de la fila 240 (Metepec NO DIGITAL) → patrón referencia-only.
### ACCIÓN 2 — Filtro en n8n (2026-05-30, a pedido del owner)
Tras el fix de Baserow, el owner pidió **implementar el filtro explícito** y dejar las filas NO DIGITAL CON su SUCURSAL para que el filtro actúe de verdad (load-bearing). Script `n8n/add_tipo_de_tienda_filter.py` (backup por workflow + dry-run + apply + activate + verify, idempotente).
1. **Operador confirmado empíricamente:** query directa Baserow REST → `not_equal` OK (excluye las 6 NO DIGITAL, 0 fugas); `is_not``ERROR_VIEW_FILTER_TYPE_DOES_NOT_EXIST` (la suposición del agente era falsa); `contains_not` también sirve pero `not_equal` es el semántico.
2. Se agregó `{field:7279, operator:"not_equal", value:"NO DIGITAL"}` a `additionalOptions.filters.fields` (AND con el filtro existente) en: `4UMRwxJdHFfOGHBp` "Obtener Info de cuenta objetivo - SUCURSAL" (**load-bearing**), `ddUEORBEtZLzsQF2` "Buscar Sucursal en Verificador de Sucursales" (defensa), `EuPdIkCORyh0skoB` "Buscar Cuenta Sucursal Bucefalo" (defensa). Los 3 quedaron `active=true`, verificado post-PUT.
3. Se **restauró la SUCURSAL** de las 6 filas NO DIGITAL (240/252/253/255/256/257) → ahora el `contains` matchea digital+no-digital y el filtro excluye las no-digital.
## VERIFICACIÓN
- `list_rows(750)` (48 filas tras borrar 4): **las 41 SUCURSAL resuelven a exactamente 1 fila digital** (0 ambiguas). Toluca/Metepec/Lerma `contains=1`→PILARES; Morelia→MORELIA 1.
- SC BUCEFALO `85935 - MP - Pilares` → 3 filas (226/241/244) **todas TIENDA=PILARES** → [2004] limit 1 determinista.
- Pilares live: 64 leads formulario, **0 con TIENDA vacía** (sin rezagados; no requiere backfill nuevo).
- **Filtro (con/sin) por query directa:** Toluca 4→1, Metepec 2→1, Lerma 2→1, Morelia 2→1 → en todas la fila que queda es la digital correcta. Prueba que el filtro es load-bearing y funciona.
- **E2E REAL en vivo:** POST al webhook de [1604] con lead `qa-test` Sucursal=Metepec → ejecución `52776` success, nodo Baserow devolvió **1 fila PILARES** (excluyó la NO DIGITAL de Metepec), réplica creada en Pilares (`DjOIZgekf3Sy2B49AWKp`) con **TIENDA=PILARES** (escrita por [2004]). Contactos de prueba (Marca `h9VXb9jy6ix9v0e5KHaY` + réplica) **borrados y verificados** (GET→error). El nodo "Envio a tienda" mandó 1 correo a la tienda (autorizado por el owner).
## EDGE-CASES / TRAMPAS
- El store **Metepec 85937 está vacío en vivo**; NO es el target digital de Metepec (lo es Pilares). El backfill de Jorge Enrique→Pilares (caso 2026-05-29) era correcto; la edición del CSV→85937 se descartó.
- Las filas NO DIGITAL se dejan **sin SUCURSAL** a propósito (referencia-only) para que el `contains` no las matchee → fix sin depender de un filtro en el workflow.
- **Operador Baserow para "≠" = `not_equal`** (NO `is_not`, que no existe). Validar SIEMPRE el operador con query directa antes de PUTear el nodo.
- El nodo Baserow n8n pasa `operator` tal cual al filter type de Baserow; el E2E confirmó que `not_equal` funciona en el nodo en vivo.
- E2E real del [1604] dispara "Envio a tienda" → **1 correo a la tienda** por cada lead de prueba. Usar `qa-test` y nombre "QA TEST" visible, y borrar Marca+réplica al terminar.
- `baserow_client` no tenía `delete_row` (se agregó). Mutaciones reversibles vía `backup_table`/`backup_workflow`.
## REUTILIZABLE
```python
# Unicidad de resolución por SUCURSAL (lo que ve el workflow)
from collections import defaultdict
from scripts.baserow_client import BaserowClient
rows = BaserowClient.from_credentials().list_rows(750)
by = defaultdict(list)
for r in rows:
s = str(r.get("SUCURSAL") or "").strip()
if s and s != "-": by[s].append(r)
amb = {s: rs for s, rs in by.items()
if len([r for r in rs if str(r.get("TIPO DE TIENDA") or "").upper() != "NO DIGITAL"]) != 1}
# amb vacío => toda SUCURSAL resuelve a 1 fila digital
```
## PENDIENTES
- Ninguno. Filtro desplegado en los 3 workflows + E2E en vivo OK. (Si en el futuro se agregan filas, recordar: NO DIGITAL nunca recibe formulario; el filtro `not_equal` las excluye.)
- Rollback disponible: `n8n/backup_add_tipo_filter_*` (workflows) y `generated/migrations/baserow_verificador_pre_*` (Baserow).
## ENLACES
- Memorias: [[verificador_tipo_de_tienda_colapso]], [[tienda_gap_formulario_sitio_web]], [[baserow_api_y_corrector]]
- Scripts: `scripts/fix_baserow_tipo_de_tienda.py`, `scripts/baserow_client.py` (+`delete_row`)
- Caso previo: [2026-05-29-tienda-vacia-formulario-sitio-web.md](2026-05-29-tienda-vacia-formulario-sitio-web.md)
+47
View File
@@ -0,0 +1,47 @@
# Registro de casos — MP Manager
> **Qué es esto:** bitácora cronológica de operaciones e investigaciones reales sobre Bucéfalo (GHL).
> Optimizada para **recall del agente**, no para humanos. Cada caso = síntoma → diagnóstico (con
> comandos exactos) → causa raíz → acción (run_ids) → verificación → edge-cases → snippets reutilizables.
>
> **Cómo encaja con el resto:**
> - `docs/PLAYBOOK_*.md` = **teoría atemporal** (metodología, taxonomía). No cambian por caso.
> - `memory/*.md` = **hechos atómicos** recuperables (reglas, gotchas, bugs). Indexados en `MEMORY.md`.
> - **Este registro** = **narrativa investigable** de cada operación, con comandos y artefactos. Liga todo.
>
> No dupliques: enlaza al playbook para la teoría y a la memoria para los hechos; aquí va lo específico del caso.
## Cuándo registrar un caso (disparador)
Crea una entrada **siempre que**:
1. **Mutes Bucéfalo** (cualquier escritura a GHL con `run_id`/snapshot), o
2. **Cierres una investigación no trivial** que llegue a una **causa raíz** (aunque no haya mutación).
No registres: lecturas triviales, consultas de un solo dato, o trabajo puramente de código del repo (eso va a git).
## Cómo registrar
1. Copia [`_PLANTILLA.md`](_PLANTILLA.md) a `docs/casos/YYYY-MM-DD-<slug>.md` y rellena (denso, comandos exactos, ids literales).
2. Agrega la fila a la tabla de abajo (más reciente arriba).
3. Crea/actualiza la(s) **memoria** relacionada(s) y enlázala(s) con `[[slug]]` desde el caso.
4. Si el caso revela teoría nueva reutilizable, considera además actualizar el `PLAYBOOK_*` correspondiente.
## Categorías
`descuadre` · `enlace_opp` · `duplicado` · `fantasma` · `cascada_n8n` · `custom_field` · `config_location` · `playwright` · `otro`
## Casos (cronológico inverso)
| Fecha | ID | Categoría | Triggers (keywords para grep) | Status | Enlace |
|---|---|---|---|---|---|
| 2026-05-30 | CASE-2026-05-30-comparativa-auditoria-completa-buckets | descuadre, cascada_n8n, config_location | `Comparativa Marca vs Sucursales auditar todos los buckets`, `opps +1 opportunities_in_brand_duplicate_link`, `present_in_other_branch_not_assigned 84 falsos positivos`, `TIENDA=METEPEC vive en Pilares 85935`, `DIGITAL_HUB_BY_SHELL hub-map en código`, `audit lee Verificador CSV no Baserow`, `PATRICIA PARRA zzBzWC4adBrdTA8WhQph réplica abandoned $0`, `redirect Windows cp1252 no utf-8`, `re-sync Marca antes de re-auditar` | resuelto | [caso](2026-05-30-comparativa-auditoria-completa-buckets.md) |
| 2026-05-30 | CASE-2026-05-30-descuadre-opp-deadlink | descuadre, cascada_n8n | `DIFERENCIA OPORTUNIDADES +2`, `descuadre positivo todos los buckets de opps en 0`, `opp Marca link muerto GET 400 Opportunity doesn't exist or is deleted`, `réplica obsoleta n8n no borró id rotado`, `contacto 2 opps Marca 1 sola viva sucursal`, `cleanup scripts no atrapan dead-link único`, `re-enlazar vs borrar opp 1:1` | resuelto | [caso](2026-05-30-descuadre-opp-deadlink.md) |
| 2026-05-30 | CASE-2026-05-30-descuadre-opp-replica-duplicada-marca | descuadre, cascada_n8n, duplicado | `DIFERENCIA OPORTUNIDADES +10`, `descuadre positivo Marca > Sucursales`, `réplicas duplicadas mismo ID Oportunidad Sucursal`, `dos opps Marca misma opp sucursal`, `opportunities_in_brand_duplicate_link`, `Cfgwp0bOtDW8zuKW CREATE en vez de UPDATE`, `Decidir Match Create vs Update`, `cleanup_brand_orphan cero huérfanas pero descuadre`, `antelmo anselmo mismo teléfono distinto contacto`, `cleanup_brand_duplicate_replica_opps.py` | parcial | [caso](2026-05-30-descuadre-opp-replica-duplicada-marca.md) |
| 2026-05-30 | CASE-2026-05-30-sucursal-tag-en-leads-digitales | custom_field, cascada_n8n | `tag sucursal y facebook-ads juntos`, `falsa atribución digital`, `createdBy.source INTEGRATION channel OAUTH`, `origen real facebook ads vs creado por sucursal`, `quitar tag sucursal lead digital`, `audit_origin_for_contact_list.py`, `fix_remove_sucursal_tag_digital.py`, `n8n no re-añade tag sucursal`, `Miza Olguin contacto SUCURSAL opp FORMULARIO`, `no sobrescribir Fuente PROSPECCIÓN` | resuelto | [caso](2026-05-30-sucursal-tag-en-leads-digitales.md) |
| 2026-05-30 | CASE-2026-05-30-verificador-tipo-de-tienda-colapso | config_location, cascada_n8n, custom_field | `TIPO DE TIENDA`, `field 7279`, `NO DIGITAL`, `colapsar filas digitales`, `1 fila canónica por location`, `hub digital Pilares`, `Metepec 85937 vacío`, `premisa no-digital 0 contactos`, `fix_baserow_tipo_de_tienda.py`, `baserow delete_row` | resuelto | [caso](2026-05-30-verificador-tipo-de-tienda-colapso.md) |
| 2026-05-29 | CASE-2026-05-29-tienda-vacia-formulario-sitio-web | custom_field, cascada_n8n, config_location | `TIENDA vacía`, `Formulario - Sitio Web TIENDA`, `Baserow 750 más de un resultado`, `Si hay más de un resultado`, `Toluca Metepec 3 filas`, `SUCURSAL contains ambiguo`, `createdBy INTEGRATION`, `delete Guco Villahermosa` | parcial | [caso](2026-05-29-tienda-vacia-formulario-sitio-web.md) |
| 2026-05-29 | CASE-2026-05-29-backfill-cf-vehiculo-temixco-marca | custom_field, cascada_n8n | `contacto Marca sin datos de vehículo`, `customFields count 0 en Marca`, `réplica Sucursal→Marca sin custom fields`, `Sincronización Sucursal CFs vacíos`, `nombres CF idénticos descarta name-mismatch`, `Temixco`, `Cristhian Hugo`, `cache stale synced_at anterior` | parcial | [caso](2026-05-29-backfill-cf-vehiculo-temixco-marca.md) |
| 2026-05-29 | CASE-2026-05-29-corrector-baserow-verificador | config_location | `Baserow`, `tabla 749 750`, `SC BUCEFALO`, `Verificador desactualizado`, `n8n no encuentra sucursal`, `PLAZA EL SALADO 85932 85956`, `cuentas_oficiales.csv`, `corrector baserow` | resuelto | [caso](2026-05-29-corrector-baserow-verificador.md) |
| 2026-05-29 | CASE-2026-05-29-n8n-2004-canal-origen-tiempo-real | cascada_n8n | `[2004]`, `ddUEORBEtZLzsQF2`, `webhook 8d574598`, `Canal de Origen tiempo real`, `Verificador 750 devuelve 0`, `Eugenia no está en Baserow 750`, `esUsuario IF n8n`, `n8n executions includeData` | resuelto | [caso](2026-05-29-n8n-2004-canal-origen-tiempo-real.md) |
| 2026-05-29 | CASE-2026-05-29-origen-sucursal-contactos-usuario | custom_field | `createdBy.source`, `WEB_USER`, `MOBILE_USER`, `createdBy no viene en el listado`, `fix_web_user_branch_contacts roto`, `origen sucursal`, `no sobrescribir Fuente de Prospecto`, `ALIANZA` | resuelto | [caso](2026-05-29-origen-sucursal-contactos-usuario.md) |
| 2026-05-29 | CASE-2026-05-29-descuadre-opp-multiempeno | descuadre, config_location, duplicado | `descuadre +1 opp`, `Can not create duplicate opportunity`, `allowDuplicateOpportunity`, `multi-empeño no replica`, `link muerto`, `réplica obsoleta` | resuelto (5 mislinks pendientes) | [caso](2026-05-29-descuadre-opp-multiempeno.md) |
+54
View File
@@ -0,0 +1,54 @@
---
id: CASE-YYYY-MM-DD-<slug>
fecha: YYYY-MM-DD
categoria: descuadre | enlace_opp | duplicado | fantasma | cascada_n8n | custom_field | config_location | playwright | otro
location_ids: [] # cuentas tocadas (incluye nombre al lado del id, ver [[name_account_with_location_id]])
run_ids: [] # script_audit run_ids generados (rollback)
snapshots: [] # rutas generated/migrations/*.json
status: resuelto | parcial | pendiente | escalado
memorias: [] # [[slug]] de memorias relacionadas
playbooks: [] # docs/PLAYBOOK_*.md relevantes
---
<!--
COMO USAR ESTA PLANTILLA (para mi, el agente):
- Copia este archivo a docs/casos/YYYY-MM-DD-<slug>.md y rellena.
- Escribe DENSO y para MI uso: comandos exactos copiables, ids literales, errores literales.
- TRIGGERS es lo mas importante: pon las frases que un grep futuro buscaria.
- Documenta tambien las atribuciones EQUIVOCADAS y por que se descartaron — ahorran horas.
- Al terminar: agrega la fila a INDEX.md y enlaza/actualiza la(s) memoria(s).
-->
## TRIGGERS
<!-- Frases/keywords/errores LITERALES que en un caso futuro me harian buscar este caso.
Incluye: sintoma del dashboard, mensajes de error de GHL, nombres de flags/campos, numeros. -->
- `...`
## SÍNTOMA
<!-- El punto de entrada: que se observo, donde, con que numero. -->
## DIAGNÓSTICO
<!-- Pasos READ-ONLY con comandos EXACTOS y que revelo cada uno.
Incluye los callejones sin salida y por que se descartaron. -->
## CAUSA RAÍZ
<!-- La causa CONFIRMADA (no la aparente). Una o varias, numeradas. -->
## ACCIÓN
<!-- Que se muto. Orden dry-run -> piloto -> lote. Comandos exactos, run_ids, snapshots.
Si no se muto (solo investigacion): "Ninguna mutacion; solo diagnostico." -->
## VERIFICACIÓN
<!-- Antes -> despues con numeros. El comando que lo confirma. -->
## EDGE-CASES / TRAMPAS
<!-- Lo que casi sale mal. Falsos positivos. Por que NO hacer X. -->
## REUTILIZABLE
<!-- Snippets/comandos directamente copiables para el proximo caso similar. -->
## PENDIENTES
<!-- Lo que quedo abierto y como retomarlo. -->
## ENLACES
<!-- Memorias [[slug]], playbooks docs/..., scripts scripts/..., artefactos generated/... -->
+127
View File
@@ -0,0 +1,127 @@
import json
import logging
import os
import re
import traceback
import uuid
from copy import deepcopy
from datetime import datetime
from logging.handlers import RotatingFileHandler
from paths import BASE_DIR, LOGS_DIR as LOG_DIR, ERROR_LOG_PATH
MAX_STRING_LENGTH = 4000
MAX_COLLECTION_ITEMS = 50
SENSITIVE_KEYS = {
"authorization",
"api_token",
"token",
"access_token",
"refresh_token",
"cookie",
"set-cookie",
"password",
"secret",
}
BEARER_RE = re.compile(r"Bearer\s+[A-Za-z0-9._\-]+", re.IGNORECASE)
def _ensure_logger():
os.makedirs(LOG_DIR, exist_ok=True)
logger = logging.getLogger("mp_manager.errors")
if logger.handlers:
return logger
logger.setLevel(logging.ERROR)
logger.propagate = False
handler = RotatingFileHandler(
ERROR_LOG_PATH,
maxBytes=5 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
return logger
def new_error_id():
return str(uuid.uuid4())
def sanitize(value, depth=0):
if depth > 6:
return "<max-depth>"
if isinstance(value, dict):
clean = {}
for key, item in list(value.items())[:MAX_COLLECTION_ITEMS]:
key_text = str(key)
if key_text.lower() in SENSITIVE_KEYS or "token" in key_text.lower():
clean[key_text] = "<redacted>"
else:
clean[key_text] = sanitize(item, depth + 1)
return clean
if isinstance(value, (list, tuple, set)):
return [sanitize(item, depth + 1) for item in list(value)[:MAX_COLLECTION_ITEMS]]
if isinstance(value, bytes):
value = value.decode("utf-8", errors="replace")
if isinstance(value, str):
text = BEARER_RE.sub("Bearer <redacted>", value)
if len(text) > MAX_STRING_LENGTH:
return text[:MAX_STRING_LENGTH] + "...<truncated>"
return text
return value
def format_exception(exc):
if exc is None:
return None
return {
"type": type(exc).__name__,
"message": sanitize(str(exc)),
"traceback": sanitize("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))),
}
def log_error(event, exc=None, context=None, *, error_id=None):
"""
Registra errores técnicos en JSONL y, si la DB está disponible, también en SQLite.
Devuelve error_id para poder correlacionarlo con la terminal o respuesta HTTP.
"""
error_id = error_id or new_error_id()
context = sanitize(deepcopy(context or {}))
exception_data = format_exception(exc)
record = {
"error_id": error_id,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"event": event,
"context": context,
"exception": exception_data,
}
logger = _ensure_logger()
logger.error(json.dumps(record, ensure_ascii=False, default=str))
try:
import db
db.insert_error_log(
error_id=error_id,
event=event,
exception_type=exception_data.get("type") if exception_data else None,
exception_message=exception_data.get("message") if exception_data else None,
context=record,
)
except Exception:
# El archivo JSONL es la fuente primaria si SQLite no está disponible.
pass
return error_id
File diff suppressed because it is too large Load Diff
+444
View File
@@ -0,0 +1,444 @@
import requests
import time
import threading
from urllib.parse import urlparse, parse_qs
import error_logging
BASE_URL = "https://services.leadconnectorhq.com"
API_VERSION = "2021-07-28"
class GHLClient:
def __init__(self):
self._last_request_times = {} # token -> float
self._lock = threading.Lock()
self._local = threading.local()
def _wait_for_rate_limit(self, token):
"""
Implementa un rate limiting estricto de 110ms entre peticiones por token (Location/Recurso),
ajustado al límite de ráfaga de GHL V2 (100 peticiones por cada 10 segundos por ubicación).
"""
with self._lock:
now = time.time()
last_time = self._last_request_times.get(token, 0)
elapsed = now - last_time
if elapsed < 0.110:
sleep_time = 0.110 - elapsed
time.sleep(sleep_time)
self._last_request_times[token] = time.time()
def _request(self, method, endpoint, token, params=None, json=None, attempts=3, version=None):
url = f"{BASE_URL}{endpoint}" if not endpoint.startswith("http") else endpoint
headers = {
"Authorization": f"Bearer {token}",
"Version": version or API_VERSION,
"Accept": "application/json",
"Content-Type": "application/json"
}
# Inicializar sesión TCP persistente por hilo para reutilización de sockets/SSL keep-alive
if not hasattr(self._local, "session"):
self._local.session = requests.Session()
self._wait_for_rate_limit(token)
last_response = None
for attempt in range(attempts):
try:
response = self._local.session.request(method, url, headers=headers, params=params, json=json, timeout=15)
last_response = response
# 429 Rate Limit - Backoff Lineal de 5 segundos
if response.status_code == 429:
sleep_sec = 5 + attempt
time.sleep(sleep_sec)
continue
# 5xx Server Error - Backoff Exponencial
if response.status_code >= 500:
sleep_sec = 2 * (2 ** attempt)
time.sleep(sleep_sec)
continue
# 401 Unauthorized - Error Inmediato
if response.status_code == 401:
error = Exception(f"GHL HTTP 401: Token inválido o no autorizado")
self._log_request_error(error, method, url, params, json, response, attempt + 1)
raise error
# Devolver JSON
if response.status_code in (200, 201):
return response.json()
elif response.status_code == 204:
return {"success": True}
else:
try:
response.raise_for_status()
except requests.exceptions.RequestException as e:
self._log_request_error(e, method, url, params, json, response, attempt + 1)
raise
except requests.exceptions.RequestException as e:
response = getattr(e, "response", None)
if response is not None and 400 <= response.status_code < 500 and response.status_code != 429:
raise e
if attempt == attempts - 1:
self._log_request_error(e, method, url, params, json, last_response, attempt + 1)
raise e
time.sleep(1 * (attempt + 1))
error = Exception(f"Fallaron todos los reintentos para la petición {method} {url}")
self._log_request_error(error, method, url, params, json, last_response, attempts)
raise error
def _log_request_error(self, exc, method, url, params, json_body, response, attempt):
context = {
"ghl_method": method,
"ghl_url": url,
"params": params,
"json_body": json_body,
"attempt": attempt,
}
if response is not None:
context.update({
"ghl_status_code": response.status_code,
"ghl_response_body": response.text[:4000],
})
error_logging.log_error("ghl_request_failed", exc, context)
# --- SERVICIOS DE CONTACTOS ---
def get_contacts(self, token, location_id, limit=100, start_after=None, start_after_id=None):
"""
Obtiene una página de contactos usando la API de GHL.
"""
params = {"locationId": location_id, "limit": min(limit, 100)}
if start_after:
params["startAfter"] = start_after
if start_after_id:
params["startAfterId"] = start_after_id
return self._request("GET", "/contacts/", token, params=params)
def get_all_contacts(self, token, location_id):
"""
Pagina todos los contactos de una location hasta agotar la API.
Sin tope artificial: el único corte es la paginación nativa de GHL
(batch vacío, batch menor al limit, total alcanzado o cursor agotado).
"""
contacts = []
start_after = None
start_after_id = None
limit = 100
while True:
data = self.get_contacts(token, location_id, limit=limit, start_after=start_after, start_after_id=start_after_id)
batch = data.get("contacts", [])
if not batch:
break
contacts.extend(batch)
meta = data.get("meta", {})
total_reported = meta.get("total", 0) or 0
if len(batch) < limit or (total_reported > 0 and len(contacts) >= total_reported):
break
next_page = meta.get("nextPage")
if next_page == "" or next_page is None:
break
next_page_url = meta.get("nextPageUrl")
if not next_page_url:
break
parsed = urlparse(next_page_url)
queries = parse_qs(parsed.query)
cursors = queries.get("startAfter")
cursor_ids = queries.get("startAfterId")
start_after = cursors[0] if cursors else None
start_after_id = cursor_ids[0] if cursor_ids else None
if not start_after and not start_after_id:
break
return contacts
def create_contact(self, token, contact_data):
return self._request("POST", "/contacts/", token, json=contact_data)
def update_contact(self, token, contact_id, contact_data):
return self._request("PUT", f"/contacts/{contact_id}", token, json=contact_data)
def delete_contact(self, token, contact_id, location_id):
return self._request("DELETE", f"/contacts/{contact_id}", token, params={"locationId": location_id})
def add_contact_tag(self, token, contact_id, tags):
# tags es una lista de strings
return self._request("POST", f"/contacts/{contact_id}/tags", token, json={"tags": tags})
def delete_contact_tag(self, token, contact_id, tag_name, location_id):
return self._request("DELETE", f"/contacts/{contact_id}/tags/{tag_name}", token, params={"locationId": location_id})
# --- SERVICIOS DE OPORTUNIDADES ---
def search_opportunities(self, token, location_id, limit=100, page=1, pipeline_id=None):
"""
Obtiene oportunidades usando el endpoint POST /opportunities/search (camelCase!).
Acepta pipeline_id opcional para filtrar por un pipeline puntual.
El filtro por pipeline se envia como query param snake_case (regla de GHL).
"""
body = {
"locationId": location_id,
"limit": min(limit, 100),
"page": page
}
params = {}
if pipeline_id:
params["pipeline_id"] = pipeline_id
return self._request("POST", "/opportunities/search", token, params=params or None, json=body)
def get_all_opportunities(self, token, location_id):
"""
Pagina todas las oportunidades de una location hasta agotar la API.
Sin tope artificial: el único corte es la paginación nativa de GHL
(batch vacío, batch menor al limit, o total alcanzado).
"""
opportunities = []
page = 1
limit = 100
while True:
try:
data = self.search_opportunities(token, location_id, limit=limit, page=page)
except Exception as e:
error_logging.log_error("ghl_get_all_opportunities_page_failed", e, {
"location_id": location_id,
"page": page,
"accumulated_opps": len(opportunities)
})
break
batch = data.get("opportunities", [])
if not batch:
break
opportunities.extend(batch)
total_reported = data.get("total", 0) or 0
if len(batch) < limit or (total_reported > 0 and len(opportunities) >= total_reported):
break
page += 1
return opportunities
def create_opportunity(self, token, opp_data):
return self._request("POST", "/opportunities/", token, json=opp_data)
def get_opportunity(self, token, opp_id):
"""GET /opportunities/{id} → trae la opp completa con su contactId,
pipelineId, pipelineStageId, status, customFields, etc. Usar tras una
mutación para refrescar el cache local con la verdad de Bucéfalo."""
return self._request("GET", f"/opportunities/{opp_id}", token)
def update_opportunity(self, token, opp_id, opp_data):
return self._request("PUT", f"/opportunities/{opp_id}", token, json=opp_data)
def update_opportunity_status(self, token, opp_id, status):
return self._request("PUT", f"/opportunities/{opp_id}/status", token, json={"status": status})
def delete_opportunity(self, token, opp_id, location_id):
return self._request("DELETE", f"/opportunities/{opp_id}", token, params={"locationId": location_id})
# --- SERVICIOS DE PIPELINES ---
def get_pipelines(self, token, location_id):
"""
Obtiene pipelines de oportunidades usando GET /opportunities/pipelines.
"""
try:
data = self._request("GET", "/opportunities/pipelines", token, params={"locationId": location_id})
return data.get("pipelines", [])
except Exception as e:
error_id = error_logging.log_error("ghl_get_opportunity_pipelines_failed", e, {"location_id": location_id})
print(f"Error fetching opportunity pipelines for {location_id}: {e} | error_id={error_id}")
try:
data = self._request("GET", "/pipelines/", token, params={"locationId": location_id})
return data.get("pipelines", [])
except Exception as e:
# Capturar error y devolver vacío para activar el fallback sintético existente.
error_id = error_logging.log_error("ghl_get_pipelines_failed", e, {"location_id": location_id})
print(f"Error fetching legacy pipelines for {location_id}: {e} | error_id={error_id}")
return []
# --- SERVICIOS DE WORKFLOWS ---
def get_workflows(self, token, location_id):
"""
Obtiene workflows usando GET /workflows/ con locationId en query params.
"""
try:
data = self._request("GET", "/workflows/", token, params={"locationId": location_id})
return data.get("workflows", [])
except Exception as e:
error_id = error_logging.log_error("ghl_get_workflows_failed", e, {"location_id": location_id})
print(f"Error fetching workflows for {location_id}: {e} | error_id={error_id}")
return []
# --- SERVICIOS DE CUSTOM FIELDS ---
def get_custom_fields(self, token, location_id):
return self._request("GET", f"/locations/{location_id}/customFields", token)
# --- SERVICIOS DE LOCATIONS ---
def get_location(self, token, location_id):
"""
Obtiene metadata de una location usando GET /locations/{locationId}.
"""
return self._request("GET", f"/locations/{location_id}", token)
# --- SERVICIOS DE ESQUEMAS DINÁMICOS (OBJECTS) ---
def get_objects_catalog(self, token, location_id):
"""
Obtiene el catálogo de objetos de GHL para inicializar y autorizar la consulta de esquemas.
"""
try:
return self._request("GET", "/objects/", token, params={"locationId": location_id}, version="2021-04-15")
except Exception:
return {}
def get_object_schema(self, token, location_id, object_key):
"""
Obtiene el esquema completo (campos estándar y personalizados) de un objeto (contact o opportunity).
"""
try:
fields = self.get_object_schema_fields(token, location_id, object_key)
return {field["name"]: field["id"] for field in fields if field.get("name") and field.get("id")}
except Exception as e:
print(f"Error al obtener esquema del objeto {object_key} para {location_id}: {e}")
return {}
def get_object_schema_fields(self, token, location_id, object_key):
"""
Obtiene la lista cruda de campos de un objeto para cachearla en SQLite.
"""
try:
self.get_objects_catalog(token, location_id)
data = self._request("GET", f"/objects/{object_key}", token, params={"locationId": location_id}, version="2021-04-15")
return data.get("fields", [])
except Exception as e:
print(f"Error al obtener campos del objeto {object_key} para {location_id}: {e}")
return []
# --- SERVICIOS DE FORMS (FORMULARIOS Y SUBMISSIONS) ---
def get_forms(self, token, location_id):
"""
Lista los formularios de una location.
GET /forms/?locationId=...
Devuelve: lista de {id, locationId, name}
"""
try:
data = self._request("GET", "/forms/", token, params={"locationId": location_id})
return data.get("forms", []) if isinstance(data, dict) else []
except Exception as e:
print(f"Error al obtener formularios de {location_id}: {e}")
return []
def get_form_submissions_page(self, token, location_id, form_id=None, page=1, limit=100,
start_at=None, end_at=None):
"""
Una página de submissions. GHL acepta page y limit (max 100), y opcional
startAt / endAt (formato 'YYYY-MM-DD' o ISO 8601; unix timestamp da 422).
NOTA: sin startAt/endAt la API parece capar el resultado a ~1 mes hacia
atras. Para histórico completo hay que paginar por ventanas de fecha.
Devuelve el dict crudo {submissions, meta, traceId}.
"""
params = {"locationId": location_id, "page": page, "limit": limit}
if form_id:
params["formId"] = form_id
if start_at:
params["startAt"] = start_at
if end_at:
params["endAt"] = end_at
return self._request("GET", "/forms/submissions", token, params=params)
def get_all_form_submissions(self, token, location_id, form_id=None,
page_size=100, max_submissions=None,
start_at=None, end_at=None,
progress_callback=None):
"""
Itera todas las páginas de submissions hasta agotar `meta.nextPage` o
alcanzar `max_submissions`. Deduplica por id. Protección anti-loop:
corta si una página se repite o si una página viene vacía.
start_at / end_at: opcionales para filtrar por createdAt (YYYY-MM-DD o ISO).
progress_callback(page_num, page_count, total_so_far): opcional.
"""
results = []
seen_ids = set()
page = 1
last_page_hash = None
empty_streak = 0
while True:
data = self.get_form_submissions_page(token, location_id, form_id=form_id,
page=page, limit=page_size,
start_at=start_at, end_at=end_at)
if not isinstance(data, dict):
break
subs = data.get("submissions") or []
if not subs:
empty_streak += 1
if empty_streak >= 2:
break
else:
empty_streak = 0
# Anti-loop: detecta misma página dos veces seguidas
page_signature = tuple(s.get("id") for s in subs)
if page_signature and page_signature == last_page_hash:
break
last_page_hash = page_signature
new_in_page = 0
for s in subs:
sid = s.get("id")
if not sid or sid in seen_ids:
continue
seen_ids.add(sid)
results.append(s)
new_in_page += 1
if max_submissions and len(results) >= max_submissions:
break
if progress_callback:
try:
progress_callback(page, len(subs), len(results))
except Exception:
pass
if max_submissions and len(results) >= max_submissions:
break
meta = data.get("meta") or {}
next_page = meta.get("nextPage")
if not next_page:
break
# Anti-loop dura: next_page no debe regresar al actual o anterior
try:
next_page_int = int(next_page)
except (TypeError, ValueError):
break
if next_page_int <= page:
break
page = next_page_int
return results
+3064
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
"""MCP server stdio para MP Manager.
Expone herramientas tipadas para que Claude Code opere el ecosistema
(audits, syncs, búsquedas, scripts) sin pasar por el SPA ni por Bash crudo.
Entry point: `python -m mcp_server`
"""
__version__ = "0.1.0"
+13
View File
@@ -0,0 +1,13 @@
"""Entry point: `python -m mcp_server` arranca el servidor MCP por stdio."""
from .server import build_server
def main():
server = build_server()
# FastMCP.run() expone stdio por defecto, que es lo que Claude Code consume.
server.run()
if __name__ == "__main__":
main()
+227
View File
@@ -0,0 +1,227 @@
"""Adapters: invocan funciones internas del proyecto desde el MCP server.
Filosofía: las tools del MCP llaman funciones Python directamente (no HTTP).
Para scripts que no exportan función pública, usan subprocess con --json y
parsean stdout. Los reportes grandes se vuelcan a `generated/agent/runs/`
y la tool devuelve solo el path + summary para no inflar el contexto LLM.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import uuid
from datetime import datetime
from typing import Any
# Asegurar que la raíz del repo esté en el PYTHONPATH cuando se ejecuta
# `python -m mcp_server` desde el cwd del proyecto.
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
import paths # noqa: E402
SCRIPTS_DIR = os.path.join(_ROOT, "scripts")
PYTHON_EXE = sys.executable
LARGE_PAYLOAD_THRESHOLD = 8000 # chars; sobre esto, volcamos a archivo
def new_run_id() -> str:
return str(uuid.uuid4())
def _agent_run_path(tool_name: str, ext: str = "json") -> str:
os.makedirs(paths.AGENT_RUNS_DIR, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
return os.path.join(paths.AGENT_RUNS_DIR, f"{tool_name}_{ts}.{ext}")
def maybe_offload(tool_name: str, payload: Any) -> dict:
"""Si el payload es grande, lo escribe a disco y devuelve {path, summary}.
Devuelve siempre un dict con la forma {ok, summary, details?, report_path?}.
"""
try:
serialized = json.dumps(payload, ensure_ascii=False)
except (TypeError, ValueError):
serialized = str(payload)
if len(serialized) > LARGE_PAYLOAD_THRESHOLD:
path = _agent_run_path(tool_name)
with open(path, "w", encoding="utf-8") as f:
f.write(serialized)
summary = _summarize(payload)
return {"ok": True, "summary": summary, "report_path": path}
return {"ok": True, "summary": _summarize(payload), "details": payload}
def _summarize(payload: Any) -> dict:
"""Genera un summary compacto: claves top-level, conteos, primeras filas."""
if isinstance(payload, dict):
out = {"type": "dict", "keys": list(payload.keys())[:20]}
if "summary" in payload:
out["summary"] = payload["summary"]
for k, v in payload.items():
if isinstance(v, list):
out[f"{k}_count"] = len(v)
return out
if isinstance(payload, list):
return {"type": "list", "count": len(payload), "first": payload[:3]}
return {"type": type(payload).__name__, "value": str(payload)[:200]}
def run_python_script(
name: str,
args: list[str] | None = None,
timeout: int = 600,
expect_json: bool = False,
) -> dict:
"""Lanza un script de `scripts/` por subprocess.
Devuelve {ok, exit_code, stdout, stderr, parsed?}. Si expect_json=True y el
script imprimió JSON, lo parsea en `parsed` y aplica maybe_offload sobre él.
"""
if not name.endswith(".py"):
name = name + ".py"
script_path = os.path.join(SCRIPTS_DIR, name)
if not os.path.isfile(script_path):
return {"ok": False, "error": f"script not found: {name}"}
cmd = [PYTHON_EXE, script_path] + (args or [])
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
cwd=_ROOT,
)
except subprocess.TimeoutExpired:
return {"ok": False, "error": "timeout", "timeout_sec": timeout}
out: dict[str, Any] = {
"ok": proc.returncode == 0,
"exit_code": proc.returncode,
"command": " ".join(args or []),
}
if expect_json and proc.stdout.strip():
try:
parsed = json.loads(proc.stdout)
offload = maybe_offload(name[:-3], parsed)
out.update(offload)
out["stderr_tail"] = proc.stderr[-500:] if proc.stderr else ""
return out
except json.JSONDecodeError:
pass
out["stdout"] = proc.stdout[-4000:]
out["stderr"] = proc.stderr[-1000:]
return out
# --- Adapters específicos (funciones Python directas) ---
def list_accounts_adapter() -> list[dict]:
"""Devuelve cuentas conocidas desde la DB local."""
import db
return db.get_accounts() or []
def get_account_adapter(location_id: str) -> dict | None:
import db
return db.get_account(location_id)
def search_contacts_adapter(
location_id: str,
query: str | None = None,
limit: int = 50,
offset: int = 0,
without_opp: bool = False,
) -> list[dict]:
import db
return db.get_contacts(location_id, search_query=query, limit=limit, offset=offset, without_opp=without_opp)
def get_contact_adapter(location_id: str, contact_id: str) -> dict | None:
import db
return db.get_contact_by_id(location_id, contact_id)
def get_opportunities_adapter(location_id: str, pipeline_id: str | None = None) -> list[dict]:
import db
return db.get_opportunities(location_id, pipeline_id=pipeline_id)
def get_pipelines_adapter(location_id: str) -> list[dict]:
import db
return db.get_pipelines(location_id)
def get_account_metrics_adapter(location_id: str) -> dict:
import db
return db.get_account_metrics(location_id)
def get_global_metrics_adapter() -> dict:
import db
return db.get_global_metrics()
def get_workflows_adapter(location_id: str | None = None) -> list[dict]:
import db
return db.get_workflows(location_id=location_id)
def get_sync_logs_adapter(limit: int = 20) -> list[dict]:
import db
return db.get_sync_logs(limit=limit)
def get_error_logs_adapter(limit: int = 50) -> list[dict]:
import db
return db.get_error_logs(limit=limit)
def sync_missing_contacts_adapter(
contact_ids: list[str] | None = None,
dry_run: bool = True,
run_id: str | None = None,
) -> dict:
"""Sincroniza contactos faltantes de sucursal → Marca.
Defaults a dry_run=True. Si dry_run=False, requiere run_id (lo genera si no
se pasa) y queda registrado en script_audit para rollback.
"""
from scripts import sync_missing_contacts_to_brand as mod
if not dry_run and not run_id:
run_id = new_run_id()
result = mod.run_sync(contact_ids=contact_ids, dry_run=dry_run, run_id=run_id)
return {"run_id": run_id, "dry_run": dry_run, "result": result}
def sync_missing_opps_adapter(
opp_ids: list[str] | None = None,
dry_run: bool = True,
run_id: str | None = None,
) -> dict:
"""Sincroniza oportunidades faltantes de sucursal → Marca."""
from scripts import sync_missing_opps_to_brand as mod
if not dry_run and not run_id:
run_id = new_run_id()
result = mod.run_sync(opp_ids=opp_ids, dry_run=dry_run, run_id=run_id)
return {"run_id": run_id, "dry_run": dry_run, "result": result}
# --- Catálogo de scripts disponibles ---
def script_catalog() -> dict:
"""Devuelve el inventario completo de scripts con su estado de auditoría."""
if not os.path.exists(paths.AGENT_AUDIT_REPORT):
return {"error": "audit_report.json no existe — corre scripts/audit_agent_readiness.py"}
with open(paths.AGENT_AUDIT_REPORT, "r", encoding="utf-8") as f:
return json.load(f)
+97
View File
@@ -0,0 +1,97 @@
"""Genera `generated/agent/tools_manifest.json`.
Fuente única de verdad navegable para LLM/humanos: lista de tools MCP,
scripts disponibles, endpoints FastAPI y su estado de cumplimiento.
Se ejecuta automáticamente al arrancar el server. También puede invocarse
manual: python -m mcp_server.manifest
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
import paths # noqa: E402
# Catálogo declarativo de tools MCP (mantener sincronizado con server.py).
TOOLS = [
{"name": "list_accounts", "category": "accounts", "mutates": False, "desc": "Lista cuentas Marca+sucursales."},
{"name": "get_account", "category": "accounts", "mutates": False, "desc": "Detalle de una cuenta."},
{"name": "get_global_metrics", "category": "metrics", "mutates": False, "desc": "Métricas globales."},
{"name": "get_account_metrics", "category": "metrics", "mutates": False, "desc": "Métricas por sucursal."},
{"name": "search_contacts", "category": "contacts", "mutates": False, "desc": "Busca contactos en cache local."},
{"name": "get_contact", "category": "contacts", "mutates": False, "desc": "Detalle de contacto."},
{"name": "get_opportunities", "category": "opps", "mutates": False, "desc": "Oportunidades por location."},
{"name": "get_pipelines", "category": "opps", "mutates": False, "desc": "Pipelines/etapas por location."},
{"name": "get_workflows", "category": "workflows", "mutates": False, "desc": "Workflows por location."},
{"name": "sync_missing_contacts", "category": "sync", "mutates": True, "desc": "Sucursal→Marca contactos faltantes (dry-run default)."},
{"name": "sync_missing_opps", "category": "sync", "mutates": True, "desc": "Sucursal→Marca opps faltantes (dry-run default)."},
{"name": "sync_logs", "category": "ops", "mutates": False, "desc": "Logs de sincronización."},
{"name": "error_logs", "category": "ops", "mutates": False, "desc": "Errores recientes."},
{"name": "agent_audit_report", "category": "ops", "mutates": False, "desc": "Reporte de salud agentica."},
{"name": "script_catalog", "category": "ops", "mutates": False, "desc": "Manifest navegable (este archivo)."},
{"name": "run_script", "category": "advanced", "mutates": True, "desc": "Ejecuta script arbitrario de scripts/."},
]
def _load_audit_report() -> dict | None:
if not os.path.exists(paths.AGENT_AUDIT_REPORT):
return None
try:
with open(paths.AGENT_AUDIT_REPORT, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def build_manifest() -> dict:
audit = _load_audit_report()
scripts: list[dict] = []
endpoints: list[dict] = []
if audit:
for s in audit.get("scripts", []):
scripts.append({
"name": s["name"],
"category": s["category"],
"is_mutator": s["is_mutator"],
"registered_in_metadata": s["registered_in_metadata"],
"has_json": s["has_json_flag"],
"has_apply": s["has_apply_flag"],
"has_run_id": s["has_run_id_flag"],
"issues": s["issues"],
"suggestion": s["suggestion"],
"docstring": s["docstring"],
})
endpoints = audit.get("endpoints", [])
return {
"generated_at": datetime.now(timezone.utc).isoformat(),
"tools": TOOLS,
"scripts": scripts,
"endpoints": endpoints,
"notes": {
"apply_confirm_token": "I-HAVE-USER-CONFIRMATION",
"dry_run_default": True,
"audit_source": "generated/agent/audit_report.json",
"refresh_audit": "python scripts/audit_agent_readiness.py",
},
}
def write_manifest() -> str:
os.makedirs(paths.AGENT_DIR, exist_ok=True)
manifest = build_manifest()
with open(paths.AGENT_MANIFEST_PATH, "w", encoding="utf-8") as f:
json.dump(manifest, f, indent=2, ensure_ascii=False)
return paths.AGENT_MANIFEST_PATH
if __name__ == "__main__":
path = write_manifest()
print(f"manifest written: {path}")
+225
View File
@@ -0,0 +1,225 @@
"""Construcción del servidor FastMCP y registro de tools.
Tools expuestas (ver docs/AGENT_TOOLS.md para el catálogo completo):
Cuentas / metadatos:
list_accounts, get_account, get_global_metrics, get_account_metrics
Datos cacheados (SQLite local refresca con sync_all_accounts):
search_contacts, get_contact, get_opportunities, get_pipelines, get_workflows
Operaciones (con dry_run por defecto, requieren confirm_token para aplicar):
sync_missing_contacts, sync_missing_opps
Operacional / observabilidad:
sync_logs, error_logs, agent_audit_report, script_catalog
Genéricas (cola larga uso avanzado):
run_script(name, args, expect_json)
"""
from __future__ import annotations
import json
import os
from typing import Any
from mcp.server.fastmcp import FastMCP
from . import adapters
from .manifest import write_manifest
# Token literal que el LLM debe pasar como `confirm_token` para aplicar
# cambios reales. Documentado en docs/AGENT_TOOLS.md y en la descripción
# de cada tool mutadora. La idea es que el LLM lo solicite explícitamente
# al usuario antes de invocar con apply=True.
APPLY_CONFIRM_TOKEN = "I-HAVE-USER-CONFIRMATION"
def _require_confirm(apply: bool, confirm_token: str | None) -> str | None:
if not apply:
return None
if confirm_token != APPLY_CONFIRM_TOKEN:
return (
f"apply=True requiere confirm_token='{APPLY_CONFIRM_TOKEN}'. "
"Pide confirmación al usuario antes de pasarlo. Default es dry-run."
)
return None
def build_server() -> FastMCP:
mcp = FastMCP("mp-manager")
# Regenerar manifest al arrancar para que siempre refleje el estado actual.
try:
write_manifest()
except Exception:
# No bloqueamos el arranque si el manifest falla; el server sigue siendo útil.
pass
# ---------- Cuentas / metadatos ----------
@mcp.tool(description="Lista todas las cuentas (Marca + 49 sucursales) desde SQLite local. Read-only.")
def list_accounts() -> dict:
accounts = adapters.list_accounts_adapter()
return adapters.maybe_offload("list_accounts", accounts)
@mcp.tool(description="Detalle de una cuenta por location_id desde SQLite local.")
def get_account(location_id: str) -> dict:
acc = adapters.get_account_adapter(location_id)
return {"ok": acc is not None, "account": acc}
@mcp.tool(description="Métricas globales: total contactos, opps, sucursales activas. Read-only desde SQLite.")
def get_global_metrics() -> dict:
return {"ok": True, "metrics": adapters.get_global_metrics_adapter()}
@mcp.tool(description="Métricas de una sucursal (contactos, opps por status, pipelines). Read-only desde SQLite.")
def get_account_metrics(location_id: str) -> dict:
return {"ok": True, "metrics": adapters.get_account_metrics_adapter(location_id)}
# ---------- Datos cacheados ----------
@mcp.tool(
description=(
"Busca contactos en una location (cache SQLite). "
"query matchea nombre/email/teléfono. "
"without_opp=True filtra contactos sin oportunidad."
)
)
def search_contacts(
location_id: str,
query: str | None = None,
limit: int = 50,
offset: int = 0,
without_opp: bool = False,
) -> dict:
rows = adapters.search_contacts_adapter(
location_id, query=query, limit=limit, offset=offset, without_opp=without_opp
)
return adapters.maybe_offload("search_contacts", rows)
@mcp.tool(description="Detalle completo de un contacto por (location_id, contact_id) desde SQLite.")
def get_contact(location_id: str, contact_id: str) -> dict:
c = adapters.get_contact_adapter(location_id, contact_id)
return {"ok": c is not None, "contact": c}
@mcp.tool(description="Oportunidades de una location (opcionalmente filtra por pipeline_id). Cache SQLite.")
def get_opportunities(location_id: str, pipeline_id: str | None = None) -> dict:
rows = adapters.get_opportunities_adapter(location_id, pipeline_id=pipeline_id)
return adapters.maybe_offload("get_opportunities", rows)
@mcp.tool(description="Pipelines y etapas de una location. Cache SQLite.")
def get_pipelines(location_id: str) -> dict:
rows = adapters.get_pipelines_adapter(location_id)
return {"ok": True, "pipelines": rows}
@mcp.tool(description="Workflows de una location (o de todas si se omite location_id). Cache SQLite.")
def get_workflows(location_id: str | None = None) -> dict:
rows = adapters.get_workflows_adapter(location_id=location_id)
return adapters.maybe_offload("get_workflows", rows)
# ---------- Operaciones (mutadoras con guard) ----------
@mcp.tool(
description=(
"Sincroniza contactos faltantes de sucursales hacia Marca. "
"Default dry_run=True (planifica, no aplica). "
"Para apply=True debes pasar confirm_token='I-HAVE-USER-CONFIRMATION' tras pedir confirmación al usuario. "
"Genera run_id automático y registra en script_audit para rollback."
)
)
def sync_missing_contacts(
contact_ids: list[str] | None = None,
apply: bool = False,
confirm_token: str | None = None,
) -> dict:
err = _require_confirm(apply, confirm_token)
if err:
return {"ok": False, "error": err}
result = adapters.sync_missing_contacts_adapter(
contact_ids=contact_ids, dry_run=not apply
)
return adapters.maybe_offload("sync_missing_contacts", result)
@mcp.tool(
description=(
"Sincroniza oportunidades faltantes de sucursales hacia Marca. "
"Default dry_run=True. Para apply=True requiere confirm_token. "
"Genera run_id y registra en script_audit para rollback."
)
)
def sync_missing_opps(
opp_ids: list[str] | None = None,
apply: bool = False,
confirm_token: str | None = None,
) -> dict:
err = _require_confirm(apply, confirm_token)
if err:
return {"ok": False, "error": err}
result = adapters.sync_missing_opps_adapter(
opp_ids=opp_ids, dry_run=not apply
)
return adapters.maybe_offload("sync_missing_opps", result)
# ---------- Observabilidad ----------
@mcp.tool(description="Logs recientes de sincronización (última corrida por sucursal). Read-only.")
def sync_logs(limit: int = 20) -> dict:
return {"ok": True, "logs": adapters.get_sync_logs_adapter(limit=limit)}
@mcp.tool(description="Errores recientes (tabla error_log + errors.jsonl). Read-only.")
def error_logs(limit: int = 50) -> dict:
return adapters.maybe_offload("error_logs", adapters.get_error_logs_adapter(limit=limit))
@mcp.tool(
description=(
"Reporte de salud agentica: scripts, endpoints, issues. "
"Regenera si stale_seconds>0 o si no existe. Lectura del JSON generado por scripts/audit_agent_readiness.py."
)
)
def agent_audit_report() -> dict:
report = adapters.script_catalog()
return adapters.maybe_offload("agent_audit_report", report)
@mcp.tool(
description=(
"Catálogo de tools y scripts disponibles (lectura del tools_manifest.json). "
"Útil al inicio de una sesión para saber qué herramientas existen."
)
)
def script_catalog() -> dict:
path = os.path.join(os.path.dirname(__file__), "..", "generated", "agent", "tools_manifest.json")
path = os.path.abspath(path)
try:
with open(path, "r", encoding="utf-8") as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError) as e:
return {"ok": False, "error": str(e)}
return adapters.maybe_offload("script_catalog", manifest)
# ---------- Genérica (cola larga) ----------
@mcp.tool(
description=(
"Ejecuta cualquier script de scripts/ por subprocess. "
"Para scripts mutadores debes pasar apply=True + confirm_token, y los args correctos del script (típicamente --apply --run-id ...). "
"Si expect_json=True intenta parsear stdout como JSON y vuelca a generated/agent/runs/ si es grande. "
"Lista de scripts disponibles en `script_catalog` / `agent_audit_report`."
)
)
def run_script(
name: str,
args: list[str] | None = None,
expect_json: bool = False,
apply: bool = False,
confirm_token: str | None = None,
timeout_sec: int = 600,
) -> dict:
err = _require_confirm(apply, confirm_token)
if err:
return {"ok": False, "error": err}
return adapters.run_python_script(
name, args=args, timeout=timeout_sec, expect_json=expect_json
)
return mcp
+2
View File
@@ -0,0 +1,2 @@
- [Bucéfalo / E3 / GHL relationship](brand_bucefalo_e3.md) — Bucéfalo es el white-label del proyecto sobre GoHighLevel; E3 es la marca dueña de Bucéfalo, no aparece en código.
- [GHL custom fields API quirks](ghl_custom_fields_api.md) — V2 no sirve para contact/opportunity; matriz de mutabilidad PUT; asimetrías read/write key.
+17
View File
@@ -0,0 +1,17 @@
---
name: brand-bucefalo-e3
description: Relación entre Bucéfalo, Consultoría E3 y GoHighLevel en este proyecto. Aclara cómo nombrar las cosas en UI/docs vs código.
metadata:
type: project
---
Bucéfalo es el **white-label** que el cliente ve. Técnicamente corre sobre GoHighLevel — por eso el código del repo habla con `services.leadconnectorhq.com` y maneja PIT tokens de GHL.
**Consultoría E3** es la **marca dueña/madre de Bucéfalo**, no aparece en el código ni en los scripts del repo. Si la encuentras mencionada como producto en algún lugar del código o de los docs internos del proyecto, es un bug — debe reemplazarse o eliminarse, no es la marca relevante para este sistema.
**Why:** la confusión entre los tres niveles (E3 marca → Bucéfalo producto → GHL infraestructura) es fácil. La regla operativa es:
- En código y scripts internos: GHL es OK porque es necesidad técnica (URLs, headers, conceptos de API).
- En UI/docs orientados al cliente: solo Bucéfalo. Nunca GHL ni E3 como producto.
- Marca propietaria (relaciones contractuales, ownership): E3.
**How to apply:** cuando el usuario menciona "el CRM", "la plataforma" o el producto en cualquier contexto operativo, refiérete a Bucéfalo. Al editar scripts no introduzcas "E3" ni "Bucéfalo" salvo que el contexto lo pida explícitamente — el código habla de GHL por implementación.
+63
View File
@@ -0,0 +1,63 @@
---
name: ghl-custom-fields-api
description: Endpoints reales de GHL para gestionar custom fields de contact/opportunity. V2 no sirve. Matriz de mutabilidad del PUT y asimetrías read/write.
metadata:
type: reference
---
Validado empíricamente el 2026-05-23 contra la cuenta DEMO (`Vf7qQl3L9vakJ8hDtQ8e`).
## Endpoints relevantes
**Para contact y opportunity custom fields, usar SIEMPRE V1**: `/locations/{locId}/customFields/*` con `Version: 2021-07-28`.
**Custom Fields V2** (`/custom-fields/*`) NO funciona para contact/opportunity — GHL responde explícitamente: *"Fields with model contact is not supported on this route"*. V2 es solo para custom objects definidos por el usuario.
**NO existe endpoint upsert** para contact/opportunity. Hay que hacer GET-or-create del lado del cliente.
## CRUD V1 disponible
- `GET /locations/{locId}/customFields` → lista todos
- `GET /locations/{locId}/customFields/{cfId}` → uno solo, response `{customField: {...}, traceId}` envuelto
- `POST /locations/{locId}/customFields` → crea
- `PUT /locations/{locId}/customFields/{cfId}` → actualiza
- `DELETE /locations/{locId}/customFields/{cfId}` → borra, response `{succeded: true}` (GHL escribe `succeded` con un solo "e")
## Matriz de mutabilidad del PUT
| Propiedad | Comportamiento |
|---|---|
| `name`, `placeholder`, `position`, `description`, `parentId`, `options` | ✅ Acepta y aplica |
| `fieldKey` | ❌ 422 "property fieldKey should not exist" — inmutable absoluto |
| `dataType` | ⚠ 200 OK pero **silently ignored** — inmutable de facto. La API miente. |
| `picklistOptions` | ❌ 422 "property picklistOptions should not exist" — usar `options` en writes |
**Why importante:** el caso `dataType` es trampa real. Si tu repair script asume que 200 = aplicado, te equivocas. Siempre verificar con GET post-PUT cuando hay duda.
## Asimetrías read vs write
| Concepto | Key al leer | Key al escribir |
|---|---|---|
| Opciones de SINGLE_OPTIONS | `picklistOptions` | `options` |
| Valor de CF en contact | `value` | `value` |
| Valor de CF en opportunity | `fieldValue` | `value` |
## dataTypes válidos
`TEXT, LARGE_TEXT, NUMERICAL, PHONE, MONETORY, CHECKBOX, SINGLE_OPTIONS, MULTIPLE_OPTIONS, FLOAT, TIME, DATE, TEXTBOX_LIST, FILE_UPLOAD, SIGNATURE, RADIO`
Nota la ortografía: `NUMERICAL` (no NUMERIC), `MONETORY` (no MONETARY). GHL las escribe así.
## Implicación para reparación
Lo único auto-reparable con seguridad:
1. Crear campo faltante (id y fieldKey serán nuevos, no copiados).
2. Sincronizar opciones (agregar las que faltan).
3. Renombrar (riesgo medio — puede romper workflows del cliente).
Lo que NO se debe auto-reparar nunca:
1. Cambiar dataType (imposible vía API; requiere delete + recreate = data loss).
2. Borrar campos sobrantes (data loss).
3. Mergear duplicados (decisión manual de cuál preservar).
Ver también [[brand-bucefalo-e3]].
+491
View File
@@ -0,0 +1,491 @@
# GHL API — Complete Endpoint Index
> Generated from 22 OpenAPI JSON specification files
> Total endpoints: 282
---
## Table of Contents
- [Associations (10 endpoints)](#associations)
- [Businesses (5 endpoints)](#businesses)
- [Calendars (41 endpoints)](#calendars)
- [Campaigns (1 endpoints)](#campaigns)
- [Companies (1 endpoints)](#companies)
- [Contacts (32 endpoints)](#contacts)
- [Conversations (29 endpoints)](#conversations)
- [Courses (1 endpoints)](#courses)
- [Custom Fields (8 endpoints)](#custom-fields)
- [Emails (5 endpoints)](#emails)
- [Forms (3 endpoints)](#forms)
- [Funnels (7 endpoints)](#funnels)
- [Invoices (42 endpoints)](#invoices)
- [Links (6 endpoints)](#links)
- [Locations (29 endpoints)](#locations)
- [Media (7 endpoints)](#media)
- [OAuth (3 endpoints)](#oauth)
- [Objects (9 endpoints)](#objects)
- [Opportunities (12 endpoints)](#opportunities)
- [Payments (23 endpoints)](#payments)
- [Users (7 endpoints)](#users)
- [Workflows (1 endpoints)](#workflows)
---
## Version Header Summary
| Category | Version Header |
|----------|---------------|
| Associations | `2021-07-28` |
| Businesses | `2021-07-28` |
| Calendars | `2021-04-15` |
| Campaigns | `2021-07-28` |
| Companies | `2021-07-28` |
| Contacts | `2021-07-28` |
| Conversations | `2021-04-15` |
| Courses | `2021-07-28` |
| Custom Fields | `2021-07-28` |
| Emails | `2021-07-28` |
| Forms | `2021-07-28` |
| Funnels | `2021-07-28` |
| Invoices | `2021-07-28` |
| Links | `2021-04-15, 2021-07-28` |
| Locations | `2021-07-28` |
| Media | `2021-07-28` |
| OAuth | `2021-07-28` |
| Objects | `2021-07-28` |
| Opportunities | `2021-07-28` |
| Payments | `2021-07-28` |
| Users | `2021-07-28` |
| Workflows | `2021-07-28` |
---
## Common API Quirks & Patterns
### User-Agent
All requests to GHL API require a `User-Agent` header. Without it, you may get 403 errors.
### POST as GET
Some endpoints use POST with a search body instead of GET (e.g., `/contacts/search`, `/opportunities/search`). This is because search parameters can be complex JSON.
### fieldValueString vs value
When updating custom fields, use `fieldValueString` for text-based fields and `value` for numeric fields. Check the field type first via schema discovery.
### DELETE with body
Some DELETE endpoints accept a request body (non-standard but supported by GHL).
### Pagination
Pagination varies by endpoint. Some use `offset`/`limit`, others use cursor-based pagination with `startAfter`.
---
## Associations
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/associations/` | Get all associations for a sub-account / location | `2021-07-28` | — |
| `POST` | `/associations/` | Create Association | `2021-07-28` | — |
| `GET` | `/associations/key/{key_name}` | Get association key by key name | `2021-07-28` | — |
| `GET` | `/associations/objectKey/{objectKey}` | Get association by object keys | `2021-07-28` | — |
| `POST` | `/associations/relations` | Create Relation for you associated entities. | `2021-07-28` | — |
| `GET` | `/associations/relations/{recordId}` | Get all relations By record Id | `2021-07-28` | — |
| `DELETE` | `/associations/relations/{relationId}` | Delete Relation | `2021-07-28` | — |
| `DELETE` | `/associations/{associationId}` | Delete Association | `2021-07-28` | — |
| `GET` | `/associations/{associationId}` | Get association by ID | `2021-07-28` | — |
| `PUT` | `/associations/{associationId}` | Update Association By Id | `2021-07-28` | — |
## Businesses
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/businesses/` | Get Businesses by Location | `2021-07-28` | — |
| `POST` | `/businesses/` | Create Business | `2021-07-28` | — |
| `DELETE` | `/businesses/{businessId}` | Delete Business | `2021-07-28` | — |
| `GET` | `/businesses/{businessId}` | Get Business | `2021-07-28` | — |
| `PUT` | `/businesses/{businessId}` | Update Business | `2021-07-28` | — |
## Calendars
> ⚠️ **Version Header:** All Calendars endpoints use `Version: 2021-04-15` (different from most other APIs which use `2021-07-28`).
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/calendars/` | Get Calendars | `2021-04-15` | — |
| `POST` | `/calendars/` | Create Calendar | `2021-04-15` | — |
| `GET` | `/calendars/appointments/{appointmentId}/notes` | Get Notes | `2021-04-15` | — |
| `POST` | `/calendars/appointments/{appointmentId}/notes` | Create Note | `2021-04-15` | — |
| `DELETE` | `/calendars/appointments/{appointmentId}/notes/{noteId}` | Delete Note | `2021-04-15` | — |
| `PUT` | `/calendars/appointments/{appointmentId}/notes/{noteId}` | Update Note | `2021-04-15` | — |
| `GET` | `/calendars/blocked-slots` | Get Blocked Slots | `2021-04-15` | — |
| `GET` | `/calendars/events` | Get Calendar Events | `2021-04-15` | — |
| `POST` | `/calendars/events/appointments` | Create appointment | `2021-04-15` | — |
| `GET` | `/calendars/events/appointments/{eventId}` | Get Appointment | `2021-04-15` | — |
| `PUT` | `/calendars/events/appointments/{eventId}` | Update Appointment | `2021-04-15` | — |
| `POST` | `/calendars/events/block-slots` | Create Block Slot | `2021-04-15` | — |
| `PUT` | `/calendars/events/block-slots/{eventId}` | Update Block Slot | `2021-04-15` | — |
| `DELETE` | `/calendars/events/{eventId}` | Delete Event | `2021-04-15` | — |
| `GET` | `/calendars/groups` | Get Groups | `2021-04-15` | — |
| `POST` | `/calendars/groups` | Create Calendar Group | `2021-04-15` | — |
| `POST` | `/calendars/groups/validate-slug` | Validate group slug | `2021-04-15` | — |
| `DELETE` | `/calendars/groups/{groupId}` | Delete Group | `2021-04-15` | — |
| `PUT` | `/calendars/groups/{groupId}` | Update Group | `2021-04-15` | — |
| `PUT` | `/calendars/groups/{groupId}/status` | Disable Group | `2021-04-15` | — |
| `GET` | `/calendars/resources/{resourceType}` | List Calendar Resources | `2021-04-15` | — |
| `POST` | `/calendars/resources/{resourceType}` | Create Calendar Resource | `2021-04-15` | — |
| `DELETE` | `/calendars/resources/{resourceType}/{id}` | Delete Calendar Resource | `2021-04-15` | — |
| `GET` | `/calendars/resources/{resourceType}/{id}` | Get Calendar Resource | `2021-04-15` | — |
| `PUT` | `/calendars/resources/{resourceType}/{id}` | Update Calendar Resource | `2021-04-15` | — |
| `POST` | `/calendars/schedules` | Create user availability schedule | `2021-04-15` | — |
| `GET` | `/calendars/schedules/search` | List user availability schedule | `2021-04-15` | — |
| `DELETE` | `/calendars/schedules/{id}` | Delete user availability schedule | `2021-04-15` | — |
| `GET` | `/calendars/schedules/{id}` | Get user availability schedule | `2021-04-15` | — |
| `PUT` | `/calendars/schedules/{id}` | Update user availability schedule | `2021-04-15` | — |
| `DELETE` | `/calendars/schedules/{id}/associations/{calendarId}` | Remove user availability schedule from a calendar | `2021-04-15` | — |
| `PUT` | `/calendars/schedules/{id}/associations/{calendarId}` | Apply user availability schedule to a calendar | `2021-04-15` | — |
| `DELETE` | `/calendars/{calendarId}` | Delete Calendar | `2021-04-15` | — |
| `GET` | `/calendars/{calendarId}` | Get Calendar | `2021-04-15` | — |
| `PUT` | `/calendars/{calendarId}` | Update Calendar | `2021-04-15` | — |
| `GET` | `/calendars/{calendarId}/free-slots` | Get Free Slots | `2021-04-15` | — |
| `GET` | `/calendars/{calendarId}/notifications` | Get notifications | `2021-04-15` | — |
| `POST` | `/calendars/{calendarId}/notifications` | Create notification | `2021-04-15` | — |
| `DELETE` | `/calendars/{calendarId}/notifications/{notificationId}` | Delete Notification | `2021-04-15` | — |
| `GET` | `/calendars/{calendarId}/notifications/{notificationId}` | Get notification | `2021-04-15` | — |
| `PUT` | `/calendars/{calendarId}/notifications/{notificationId}` | Update notification | `2021-04-15` | — |
## Campaigns
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/campaigns/` | Get Campaigns | `2021-07-28` | — |
## Companies
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/companies/{companyId}` | Get Company | `2021-07-28` | — |
## Contacts
> ⚠️ **Deprecation:** `GET /contacts/` is deprecated. Use `POST /contacts/search` instead.
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/contacts/` | Get Contacts | `2021-07-28` | ⚠️ YES |
| `POST` | `/contacts/` | Create Contact | `2021-07-28` | — |
| `POST` | `/contacts/bulk/business` | Add/Remove Contacts From Business | `2021-07-28` | — |
| `POST` | `/contacts/bulk/tags/update/{type}` | Update Contacts Tags | `2021-07-28` | — |
| `GET` | `/contacts/business/{businessId}` | Get Contacts By BusinessId | `2021-07-28` | — |
| `POST` | `/contacts/search` | Search Contacts | `2021-07-28` | — |
| `GET` | `/contacts/search/duplicate` | Get Duplicate Contact | `2021-07-28` | — |
| `POST` | `/contacts/upsert` | Upsert Contact | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}` | Delete Contact | `2021-07-28` | — |
| `GET` | `/contacts/{contactId}` | Get Contact | `2021-07-28` | — |
| `PUT` | `/contacts/{contactId}` | Update Contact | `2021-07-28` | — |
| `GET` | `/contacts/{contactId}/appointments` | Get Appointments for Contact | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}/campaigns/removeAll` | Remove Contact From Every Campaign | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}/campaigns/{campaignId}` | Remove Contact From Campaign | `2021-07-28` | — |
| `POST` | `/contacts/{contactId}/campaigns/{campaignId}` | Add Contact to Campaign | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}/followers` | Remove Followers | `2021-07-28` | — |
| `POST` | `/contacts/{contactId}/followers` | Add Followers | `2021-07-28` | — |
| `GET` | `/contacts/{contactId}/notes` | Get All Notes | `2021-07-28` | — |
| `POST` | `/contacts/{contactId}/notes` | Create Note | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}/notes/{id}` | Delete Note | `2021-07-28` | — |
| `GET` | `/contacts/{contactId}/notes/{id}` | Get Note | `2021-07-28` | — |
| `PUT` | `/contacts/{contactId}/notes/{id}` | Update Note | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}/tags` | Remove Tags | `2021-07-28` | — |
| `POST` | `/contacts/{contactId}/tags` | Add Tags | `2021-07-28` | — |
| `GET` | `/contacts/{contactId}/tasks` | Get all Tasks | `2021-07-28` | — |
| `POST` | `/contacts/{contactId}/tasks` | Create Task | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}/tasks/{taskId}` | Delete Task | `2021-07-28` | — |
| `GET` | `/contacts/{contactId}/tasks/{taskId}` | Get Task | `2021-07-28` | — |
| `PUT` | `/contacts/{contactId}/tasks/{taskId}` | Update Task | `2021-07-28` | — |
| `PUT` | `/contacts/{contactId}/tasks/{taskId}/completed` | Update Task Completed | `2021-07-28` | — |
| `DELETE` | `/contacts/{contactId}/workflow/{workflowId}` | Delete Contact from Workflow | `2021-07-28` | — |
| `POST` | `/contacts/{contactId}/workflow/{workflowId}` | Add Contact to Workflow | `2021-07-28` | — |
## Conversations
> ⚠️ **Version Header:** All Conversations endpoints use `Version: 2021-04-15` (different from most other APIs which use `2021-07-28`).
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `POST` | `/conversations/` | Create Conversation | `2021-04-15` | — |
| `GET` | `/conversations/locations/{locationId}/messages/{messageId}/transcription` | Get transcription by Message ID | `2021-04-15` | — |
| `GET` | `/conversations/locations/{locationId}/messages/{messageId}/transcription/download` | Download transcription by Message ID | `2021-04-15` | — |
| `POST` | `/conversations/messages` | Send a new message | `2021-04-15` | — |
| `DELETE` | `/conversations/messages/email/{emailMessageId}/schedule` | Cancel a scheduled email message. | `—` | — |
| `GET` | `/conversations/messages/email/{id}` | Get email by Id | `—` | — |
| `GET` | `/conversations/messages/export` | Export messages by location ID | `2021-04-15` | — |
| `POST` | `/conversations/messages/inbound` | Add an inbound message | `2021-04-15` | — |
| `POST` | `/conversations/messages/outbound` | Add an external outbound call | `2021-04-15` | — |
| `POST` | `/conversations/messages/review-reply` | Send a review reply to Google My Business | `2021-04-15` | — |
| `POST` | `/conversations/messages/upload` | Upload file attachments | `2021-04-15` | — |
| `POST` | `/conversations/messages/upload/complete` | Complete file upload | `2021-04-15` | — |
| `POST` | `/conversations/messages/upload/initiate` | Initiate file upload to GCS | `2021-04-15` | — |
| `GET` | `/conversations/messages/{id}` | Get message by message id | `2021-04-15` | — |
| `PUT` | `/conversations/messages/{messageId}/attachments` | Add message attachments | `2021-04-15` | — |
| `GET` | `/conversations/messages/{messageId}/locations/{locationId}/recording` | Get Recording by Message ID | `2021-04-15` | — |
| `DELETE` | `/conversations/messages/{messageId}/schedule` | Cancel a scheduled message. | `2021-04-15` | — |
| `PUT` | `/conversations/messages/{messageId}/status` | Update message status | `2021-04-15` | — |
| `GET` | `/conversations/preferences/custom-subtypes` | Get All Custom Subtypes | `2021-04-15` | — |
| `POST` | `/conversations/preferences/custom-subtypes` | Create Custom Subtype | `2021-04-15` | — |
| `PUT` | `/conversations/preferences/custom-subtypes/{id}` | Update Custom Subtype | `2021-04-15` | — |
| `GET` | `/conversations/preferences/unsubscriptions/status` | Get Contact Unsubscription Status | `2021-04-15` | — |
| `POST` | `/conversations/preferences/unsubscriptions/user-change` | User Subscription Change | `2021-04-15` | — |
| `POST` | `/conversations/providers/live-chat/typing` | Agent/Ai-Bot is typing a message indicator for live chat | `2021-04-15` | — |
| `GET` | `/conversations/search` | Search Conversations | `2021-04-15` | — |
| `DELETE` | `/conversations/{conversationId}` | Delete Conversation | `2021-04-15` | — |
| `GET` | `/conversations/{conversationId}` | Get Conversation | `2021-04-15` | — |
| `PUT` | `/conversations/{conversationId}` | Update Conversation | `2021-04-15` | — |
| `GET` | `/conversations/{conversationId}/messages` | Get messages by conversation id | `2021-04-15` | — |
## Courses
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `POST` | `/courses/courses-exporter/public/import` | Import Courses | `2021-07-28` | — |
## Custom Fields
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `POST` | `/custom-fields/` | Create Custom Field | `2021-07-28` | — |
| `POST` | `/custom-fields/folder` | Create Custom Field Folder | `2021-07-28` | — |
| `DELETE` | `/custom-fields/folder/{id}` | Delete Custom Field Folder | `2021-07-28` | — |
| `PUT` | `/custom-fields/folder/{id}` | Update Custom Field Folder Name | `2021-07-28` | — |
| `GET` | `/custom-fields/object-key/{objectKey}` | Get Custom Fields By Object Key | `2021-07-28` | — |
| `DELETE` | `/custom-fields/{id}` | Delete Custom Field By Id | `2021-07-28` | — |
| `GET` | `/custom-fields/{id}` | Get Custom Field / Folder By Id | `2021-07-28` | — |
| `PUT` | `/custom-fields/{id}` | Update Custom Field By Id | `2021-07-28` | — |
## Emails
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/emails/builder` | Fetch email templates | `2021-07-28` | — |
| `POST` | `/emails/builder` | Create a new template | `2021-07-28` | — |
| `POST` | `/emails/builder/data` | Update a template | `2021-07-28` | — |
| `DELETE` | `/emails/builder/{locationId}/{templateId}` | Delete a template | `2021-07-28` | — |
| `GET` | `/emails/schedule` | Get Campaigns | `2021-07-28` | — |
## Forms
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/forms/` | Get Forms | `2021-07-28` | — |
| `GET` | `/forms/submissions` | Get Forms Submissions | `2021-07-28` | — |
| `POST` | `/forms/upload-custom-files` | Upload files to custom fields | `2021-07-28` | — |
## Funnels
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/funnels/funnel/list` | Fetch List of Funnels | `—` | — |
| `POST` | `/funnels/lookup/redirect` | Create Redirect | `2021-07-28` | — |
| `GET` | `/funnels/lookup/redirect/list` | Fetch List of Redirects | `2021-07-28` | — |
| `DELETE` | `/funnels/lookup/redirect/{id}` | Delete Redirect By Id | `2021-07-28` | — |
| `PATCH` | `/funnels/lookup/redirect/{id}` | Update Redirect By Id | `2021-07-28` | — |
| `GET` | `/funnels/page` | Fetch list of funnel pages | `—` | — |
| `GET` | `/funnels/page/count` | Fetch count of funnel pages | `—` | — |
## Invoices
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/invoices/` | List invoices | `2021-07-28` | — |
| `POST` | `/invoices/` | Create Invoice | `2021-07-28` | — |
| `POST` | `/invoices/estimate` | Create New Estimate | `2021-07-28` | — |
| `GET` | `/invoices/estimate/list` | List Estimates | `2021-07-28` | — |
| `GET` | `/invoices/estimate/number/generate` | Generate Estimate Number | `2021-07-28` | — |
| `PATCH` | `/invoices/estimate/stats/last-visited-at` | Update estimate last visited at | `2021-07-28` | — |
| `GET` | `/invoices/estimate/template` | List Estimate Templates | `2021-07-28` | — |
| `POST` | `/invoices/estimate/template` | Create Estimate Template | `2021-07-28` | — |
| `GET` | `/invoices/estimate/template/preview` | Preview Estimate Template | `2021-07-28` | — |
| `DELETE` | `/invoices/estimate/template/{templateId}` | Delete Estimate Template | `2021-07-28` | — |
| `PUT` | `/invoices/estimate/template/{templateId}` | Update Estimate Template | `2021-07-28` | — |
| `DELETE` | `/invoices/estimate/{estimateId}` | Delete Estimate | `2021-07-28` | — |
| `PUT` | `/invoices/estimate/{estimateId}` | Update Estimate | `2021-07-28` | — |
| `POST` | `/invoices/estimate/{estimateId}/invoice` | Create Invoice from Estimate | `2021-07-28` | — |
| `POST` | `/invoices/estimate/{estimateId}/send` | Send Estimate | `2021-07-28` | — |
| `GET` | `/invoices/generate-invoice-number` | Generate Invoice Number | `2021-07-28` | — |
| `GET` | `/invoices/schedule` | List schedules | `2021-07-28` | — |
| `POST` | `/invoices/schedule` | Create Invoice Schedule | `2021-07-28` | — |
| `DELETE` | `/invoices/schedule/{scheduleId}` | Delete schedule | `2021-07-28` | — |
| `GET` | `/invoices/schedule/{scheduleId}` | Get an schedule | `2021-07-28` | — |
| `PUT` | `/invoices/schedule/{scheduleId}` | Update schedule | `2021-07-28` | — |
| `POST` | `/invoices/schedule/{scheduleId}/auto-payment` | Manage Auto payment for an schedule invoice | `2021-07-28` | — |
| `POST` | `/invoices/schedule/{scheduleId}/cancel` | Cancel an scheduled invoice | `2021-07-28` | — |
| `POST` | `/invoices/schedule/{scheduleId}/schedule` | Schedule an schedule invoice | `2021-07-28` | — |
| `POST` | `/invoices/schedule/{scheduleId}/updateAndSchedule` | Update scheduled recurring invoice | `2021-07-28` | — |
| `GET` | `/invoices/settings` | Get Invoice Settings | `2021-07-28` | — |
| `PATCH` | `/invoices/stats/last-visited-at` | Update invoice last visited at | `2021-07-28` | — |
| `GET` | `/invoices/template` | List templates | `2021-07-28` | — |
| `POST` | `/invoices/template` | Create template | `2021-07-28` | — |
| `DELETE` | `/invoices/template/{templateId}` | Delete template | `2021-07-28` | — |
| `GET` | `/invoices/template/{templateId}` | Get an template | `2021-07-28` | — |
| `PUT` | `/invoices/template/{templateId}` | Update template | `2021-07-28` | — |
| `PATCH` | `/invoices/template/{templateId}/late-fees-configuration` | Update template late fees configuration | `2021-07-28` | — |
| `PATCH` | `/invoices/template/{templateId}/payment-methods-configuration` | Update template late fees configuration | `2021-07-28` | — |
| `POST` | `/invoices/text2pay` | Create & Send | `2021-07-28` | — |
| `DELETE` | `/invoices/{invoiceId}` | Delete invoice | `2021-07-28` | — |
| `GET` | `/invoices/{invoiceId}` | Get invoice | `2021-07-28` | — |
| `PUT` | `/invoices/{invoiceId}` | Update invoice | `2021-07-28` | — |
| `PATCH` | `/invoices/{invoiceId}/late-fees-configuration` | Update invoice late fees configuration | `2021-07-28` | — |
| `POST` | `/invoices/{invoiceId}/record-payment` | Record a manual payment for an invoice | `2021-07-28` | — |
| `POST` | `/invoices/{invoiceId}/send` | Send invoice | `2021-07-28` | — |
| `POST` | `/invoices/{invoiceId}/void` | Void invoice | `2021-07-28` | — |
## Links
> ⚠️ **Mixed Versions:** Most Links endpoints use `2021-07-28`, but `GET /links/search` uses `Version: 2021-04-15`.
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/links/` | Get Links | `2021-07-28` | — |
| `POST` | `/links/` | Create Link | `2021-07-28` | — |
| `GET` | `/links/id/{linkId}` | Get Link by ID | `2021-07-28` | — |
| `GET` | `/links/search` | Search Trigger Links | `2021-04-15` | — |
| `DELETE` | `/links/{linkId}` | Delete Link | `2021-07-28` | — |
| `PUT` | `/links/{linkId}` | Update Link | `2021-07-28` | — |
## Locations
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `POST` | `/locations/` | Create Sub-Account (Formerly Location) | `2021-07-28` | — |
| `GET` | `/locations/search` | Search | `2021-07-28` | — |
| `DELETE` | `/locations/{locationId}` | Delete Sub-Account (Formerly Location) | `2021-07-28` | — |
| `GET` | `/locations/{locationId}` | Get Sub-Account (Formerly Location) | `2021-07-28` | — |
| `PUT` | `/locations/{locationId}` | Put Sub-Account (Formerly Location) | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/customFields` | Get Custom Fields | `2021-07-28` | — |
| `POST` | `/locations/{locationId}/customFields` | Create Custom Field | `2021-07-28` | — |
| `POST` | `/locations/{locationId}/customFields/upload` | Uploads File to customFields | `2021-07-28` | — |
| `DELETE` | `/locations/{locationId}/customFields/{id}` | Delete Custom Field | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/customFields/{id}` | Get Custom Field | `2021-07-28` | — |
| `PUT` | `/locations/{locationId}/customFields/{id}` | Update Custom Field | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/customValues` | Get Custom Values | `2021-07-28` | — |
| `POST` | `/locations/{locationId}/customValues` | Create Custom Value | `2021-07-28` | — |
| `DELETE` | `/locations/{locationId}/customValues/{id}` | Delete Custom Value | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/customValues/{id}` | Get Custom Value | `2021-07-28` | — |
| `PUT` | `/locations/{locationId}/customValues/{id}` | Update Custom Value | `2021-07-28` | — |
| `POST` | `/locations/{locationId}/recurring-tasks` | Create Recurring Task | `2021-07-28` | — |
| `DELETE` | `/locations/{locationId}/recurring-tasks/{id}` | Delete Recurring Task | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/recurring-tasks/{id}` | Get Recurring Task By Id | `2021-07-28` | — |
| `PUT` | `/locations/{locationId}/recurring-tasks/{id}` | Update Recurring Task | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/tags` | Get Tags | `2021-07-28` | — |
| `POST` | `/locations/{locationId}/tags` | Create Tag | `2021-07-28` | — |
| `DELETE` | `/locations/{locationId}/tags/{tagId}` | Delete tag | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/tags/{tagId}` | Get tag by id | `2021-07-28` | — |
| `PUT` | `/locations/{locationId}/tags/{tagId}` | Update tag | `2021-07-28` | — |
| `POST` | `/locations/{locationId}/tasks/search` | Task Search Filter | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/templates` | GET all or email/sms templates | `2021-07-28` | — |
| `DELETE` | `/locations/{locationId}/templates/{id}` | DELETE an email/sms template | `2021-07-28` | — |
| `GET` | `/locations/{locationId}/timezones` | Fetch Timezones | `2021-07-28` | — |
## Media
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `PUT` | `/medias/delete-files` | Bulk Delete / Trash Files or Folders | `2021-07-28` | — |
| `GET` | `/medias/files` | Get List of Files/ Folders | `2021-07-28` | — |
| `POST` | `/medias/folder` | Create Folder | `2021-07-28` | — |
| `PUT` | `/medias/update-files` | Bulk Update Files/ Folders | `2021-07-28` | — |
| `POST` | `/medias/upload-file` | Upload File into Media Storage | `2021-07-28` | — |
| `DELETE` | `/medias/{id}` | Delete File or Folder | `2021-07-28` | — |
| `POST` | `/medias/{id}` | Update File/ Folder | `2021-07-28` | — |
## OAuth
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/oauth/installedLocations` | Get Location where app is installed | `2021-07-28` | — |
| `POST` | `/oauth/locationToken` | Get Location Access Token from Agency Token | `2021-07-28` | — |
| `POST` | `/oauth/token` | Get Access Token | `—` | — |
## Objects
> ⚠️ **Schema Discovery:** Use `GET /objects/{key}?locationId=LOC_ID` with `Version: 2021-07-28` for schema discovery. Object key for custom objects must include `custom_objects.` prefix.
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/objects/` | Get all objects for a location | `2021-07-28` | — |
| `POST` | `/objects/` | Create Custom Object | `2021-07-28` | — |
| `GET` | `/objects/{key}` | Get Object Schema by key / id | `2021-07-28` | — |
| `PUT` | `/objects/{key}` | Update Object Schema By Key / Id | `2021-07-28` | — |
| `POST` | `/objects/{schemaKey}/records` | Create Record | `2021-07-28` | — |
| `POST` | `/objects/{schemaKey}/records/search` | Search Object Records | `2021-07-28` | — |
| `DELETE` | `/objects/{schemaKey}/records/{id}` | Delete Record | `2021-07-28` | — |
| `GET` | `/objects/{schemaKey}/records/{id}` | Get Record By Id | `2021-07-28` | — |
| `PUT` | `/objects/{schemaKey}/records/{id}` | Update Record | `2021-07-28` | — |
## Opportunities
> ⚠️ **Search Quirk:** The `POST /opportunities/search` endpoint sometimes has issues filtering by `contactId` directly. Use the newer search endpoint or work around with custom filters.
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `POST` | `/opportunities/` | Create Opportunity | `2021-07-28` | — |
| `GET` | `/opportunities/lost-reason` | Get lost reason | `2021-07-28` | — |
| `GET` | `/opportunities/pipelines` | Get Pipelines | `2021-07-28` | — |
| `GET` | `/opportunities/search` | Search Opportunity | `2021-07-28` | — |
| `POST` | `/opportunities/search` | Search Opportunities | `2021-07-28` | — |
| `POST` | `/opportunities/upsert` | Upsert Opportunity | `2021-07-28` | — |
| `DELETE` | `/opportunities/{id}` | Delete Opportunity | `2021-07-28` | — |
| `GET` | `/opportunities/{id}` | Get Opportunity | `2021-07-28` | — |
| `PUT` | `/opportunities/{id}` | Update Opportunity | `2021-07-28` | — |
| `DELETE` | `/opportunities/{id}/followers` | Remove Followers | `2021-07-28` | — |
| `POST` | `/opportunities/{id}/followers` | Add Followers | `2021-07-28` | — |
| `PUT` | `/opportunities/{id}/status` | Update Opportunity Status | `2021-07-28` | — |
## Payments
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `DELETE` | `/payments/coupon` | Delete Coupon | `2021-07-28` | — |
| `GET` | `/payments/coupon` | Fetch Coupon | `2021-07-28` | — |
| `POST` | `/payments/coupon` | Create Coupon | `2021-07-28` | — |
| `PUT` | `/payments/coupon` | Update Coupon | `2021-07-28` | — |
| `GET` | `/payments/coupon/list` | List Coupons | `2021-07-28` | — |
| `PUT` | `/payments/custom-provider/capabilities` | Custom-provider marketplace app update capabilities | `2021-07-28` | — |
| `GET` | `/payments/custom-provider/connect` | Fetch given provider config | `2021-07-28` | — |
| `POST` | `/payments/custom-provider/connect` | Create new provider config | `2021-07-28` | — |
| `POST` | `/payments/custom-provider/disconnect` | Disconnect existing provider config | `2021-07-28` | — |
| `DELETE` | `/payments/custom-provider/provider` | Deleting an existing integration | `2021-07-28` | — |
| `POST` | `/payments/custom-provider/provider` | Create new integration | `2021-07-28` | — |
| `GET` | `/payments/integrations/provider/whitelabel` | List White-label Integration Providers | `2021-07-28` | — |
| `POST` | `/payments/integrations/provider/whitelabel` | Create White-label Integration Provider | `2021-07-28` | — |
| `GET` | `/payments/orders` | List Orders | `2021-07-28` | — |
| `GET` | `/payments/orders/{orderId}` | Get Order by ID | `2021-07-28` | — |
| `GET` | `/payments/orders/{orderId}/fulfillments` | List fulfillment | `2021-07-28` | — |
| `POST` | `/payments/orders/{orderId}/fulfillments` | Create order fulfillment | `2021-07-28` | — |
| `GET` | `/payments/orders/{orderId}/notes` | List Order Notes | `2021-07-28` | — |
| `POST` | `/payments/orders/{orderId}/record-payment` | Record Order Payment | `2021-07-28` | — |
| `GET` | `/payments/subscriptions` | List Subscriptions | `2021-07-28` | — |
| `GET` | `/payments/subscriptions/{subscriptionId}` | Get Subscription by ID | `2021-07-28` | — |
| `GET` | `/payments/transactions` | List Transactions | `2021-07-28` | — |
| `GET` | `/payments/transactions/{transactionId}` | Get Transaction by ID | `2021-07-28` | — |
## Users
> ⚠️ **Deprecations:** Email update is deprecated. User deletion is asynchronous (returns 202 Accepted). `GET /users/` is deprecated — use `GET /users/search` instead.
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/users/` | Get User by Location | `2021-07-28` | ⚠️ YES |
| `POST` | `/users/` | Create User | `2021-07-28` | — |
| `GET` | `/users/search` | Search Users | `2021-07-28` | — |
| `POST` | `/users/search/filter-by-email` | Filter Users by Email | `2021-07-28` | — |
| `DELETE` | `/users/{userId}` | Delete User | `2021-07-28` | — |
| `GET` | `/users/{userId}` | Get User | `2021-07-28` | — |
| `PUT` | `/users/{userId}` | Update User | `2021-07-28` | — |
## Workflows
| Method | Path | Summary | Version | Deprecated |
|--------|------|---------|---------|------------|
| `GET` | `/workflows/` | Get Workflow | `2021-07-28` | — |
---
*Documentation generated from GHL OpenAPI specifications. Always verify against the official GHL API documentation for the latest changes.*
File diff suppressed because it is too large Load Diff
+659
View File
@@ -0,0 +1,659 @@
# GHL Businesses API
**Endpoints:** 5
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /businesses/](#get--businesses-) — Get Businesses by Location
- [POST /businesses/](#post--businesses-) — Create Business
- [DELETE /businesses/{businessId}](#delete--businesses-businessId) — Delete Business
- [GET /businesses/{businessId}](#get--businesses-businessId) — Get Business
- [PUT /businesses/{businessId}](#put--businesses-businessId) — Update Business
---
## GET /businesses/
**Summary:** Get Businesses by Location
Get Businesses by Location
**Version Header:** `2021-07-28`
**Operation ID:** `get-businesses-by-location`
**Tags:** Businesses
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/businesses/?locationId=5DP4iH6HLkQsiKESj6rh&limit=100&skip=10' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/businesses/?locationId=5DP4iH6HLkQsiKESj6rh&limit=100&skip=10', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET businesses",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/businesses/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "5DP4iH6HLkQsiKESj6rh"
},
{
"name": "limit",
"value": "100"
},
{
"name": "skip",
"value": "10"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | [example: `5DP4iH6HLkQsiKESj6rh`] |
| `limit` | Query | `string` | — | [example: `100`] |
| `skip` | Query | `string` | — | [example: `10`] |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **businesses** (array of BusinessDto) (required): Business Response |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /businesses/
**Summary:** Create Business
Create Business
**Version Header:** `2021-07-28`
**Operation ID:** `create-business`
**Tags:** Businesses
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/businesses/' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/businesses/', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST businesses",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/businesses/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **name** (string) (required) (e.g. `Microsoft`)
- **locationId** (string) (required) (e.g. `5DP4iH6HLkQsiKESj6rh`)
- **phone** (string) (e.g. `+18832327657`)
- **email** (string) (e.g. `john@deo.com`)
- **website** (string) (e.g. `www.xyz.com`)
- **address** (string) (e.g. `street adress`)
- **city** (string) (e.g. `new york`)
- **postalCode** (string) (e.g. `12312312`)
- **state** (string) (e.g. `new york`)
- **country** (string) (e.g. `us`)
- **description** (string) (e.g. `business description`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | Successful response | - **success** (boolean) (required) (e.g. `True`): Success Value<br> - **buiseness** (string) (required): Business Response |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## DELETE /businesses/{businessId}
**Summary:** Delete Business
Delete Business
**Version Header:** `2021-07-28`
**Operation ID:** `delete-Business`
**Tags:** Businesses
### cURL
```bash
curl -X DELETE 'https://rest.gohighlevel.com/v1/businesses/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('DELETE', '/v1/businesses/VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - DELETE VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "DELETE",
"url": "https://rest.gohighlevel.com/v1/businesses/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `businessId` | Path | `string` | ✅ | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **success** (boolean) (required) (e.g. `True`): Success value |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /businesses/{businessId}
**Summary:** Get Business
Get Business
**Version Header:** `2021-07-28`
**Operation ID:** `get-business`
**Tags:** Businesses
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/businesses/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/businesses/VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/businesses/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `businessId` | Path | `string` | ✅ | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **business** (string) (required): Business Response |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## PUT /businesses/{businessId}
**Summary:** Update Business
Update Business
**Version Header:** `2021-07-28`
**Operation ID:** `update-business`
**Tags:** Businesses
### cURL
```bash
curl -X PUT 'https://rest.gohighlevel.com/v1/businesses/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('PUT', '/v1/businesses/VALUE', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - PUT VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "PUT",
"url": "https://rest.gohighlevel.com/v1/businesses/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `businessId` | Path | `string` | ✅ | |
### Request Body Schema
- **name** (string) (e.g. `Microsoft`)
- **phone** (string) (e.g. `+18832327657`)
- **email** (string) (e.g. `john@deo.com`)
- **postalCode** (string) (e.g. `12312312`)
- **website** (string) (e.g. `www.xyz.com`)
- **address** (string) (e.g. `street adress`)
- **state** (string) (e.g. `new york`)
- **city** (string) (e.g. `new york`)
- **country** (string) (e.g. `us`)
- **description** (string) (e.g. `business description`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **success** (boolean) (required) (e.g. `True`): Success Value<br> - **buiseness** (string) (required): Business Response |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
File diff suppressed because it is too large Load Diff
+150
View File
@@ -0,0 +1,150 @@
# GHL Campaigns API
**Endpoints:** 1
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /campaigns/](#get--campaigns-) — Get Campaigns
---
## GET /campaigns/
**Summary:** Get Campaigns
Get Campaigns
**Version Header:** `2021-07-28`
**Operation ID:** `get-campaigns`
**Tags:** Campaigns
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/campaigns/?locationId=VALUE&status=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/campaigns/?locationId=VALUE&status=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET campaigns",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/campaigns/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "status",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `status` | Query | `string` | — | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **campaigns** (array of campaignsSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
+136
View File
@@ -0,0 +1,136 @@
# GHL Companies API
**Endpoints:** 1
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /companies/{companyId}](#get--companies-companyId) — Get Company
---
## GET /companies/{companyId}
**Summary:** Get Company
Get Comapny
**Version Header:** `2021-07-28`
**Operation ID:** `get-company`
**Tags:** Companies
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/companies/ve9EPM428h8vShlRW1KT' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/companies/ve9EPM428h8vShlRW1KT', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET ve9EPM428h8vShlRW1KT",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/companies/ve9EPM428h8vShlRW1KT",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `companyId` | Path | `string` | ✅ | [example: `ve9EPM428h8vShlRW1KT`] |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **company** (GetCompanyByIdSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
# GHL Courses API
**Endpoints:** 1
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [POST /courses/courses-exporter/public/import](#post--courses-courses-exporter-public-import) — Import Courses
---
## POST /courses/courses-exporter/public/import
**Summary:** Import Courses
Import Courses through public channels
**Version Header:** `2021-07-28`
**Operation ID:** `import-courses`
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/courses/courses-exporter/public/import' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/courses/courses-exporter/public/import', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST import",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/courses/courses-exporter/public/import",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **locationId** (string) (required)
- **userId** (string)
- **products** (array of ProductInterface) (required)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | | — |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
File diff suppressed because it is too large Load Diff
+759
View File
@@ -0,0 +1,759 @@
# GHL Emails API
**Endpoints:** 5
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /emails/builder](#get--emails-builder) — Fetch email templates
- [POST /emails/builder](#post--emails-builder) — Create a new template
- [POST /emails/builder/data](#post--emails-builder-data) — Update a template
- [DELETE /emails/builder/{locationId}/{templateId}](#delete--emails-builder-locationId-templateId) — Delete a template
- [GET /emails/schedule](#get--emails-schedule) — Get Campaigns
---
## GET /emails/builder
**Summary:** Fetch email templates
Fetch email templates by location id
**Version Header:** `2021-07-28`
**Operation ID:** `fetch-template`
**Tags:** Templates
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/emails/builder?locationId=VALUE&limit=VALUE&offset=VALUE&search=VALUE&sortByDate=VALUE&archived=VALUE&builderVersion=1&name=VALUE&parentId=VALUE&originId=VALUE&templatesOnly=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/emails/builder?locationId=VALUE&limit=VALUE&offset=VALUE&search=VALUE&sortByDate=VALUE&archived=VALUE&builderVersion=VALUE&name=VALUE&parentId=VALUE&originId=VALUE&templatesOnly=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET builder",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/emails/builder",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "offset",
"value": "VALUE"
},
{
"name": "search",
"value": "VALUE"
},
{
"name": "sortByDate",
"value": "VALUE"
},
{
"name": "archived",
"value": "VALUE"
},
{
"name": "builderVersion",
"value": "VALUE"
},
{
"name": "name",
"value": "VALUE"
},
{
"name": "parentId",
"value": "VALUE"
},
{
"name": "originId",
"value": "VALUE"
},
{
"name": "templatesOnly",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `limit` | Query | `string` | — | |
| `offset` | Query | `string` | — | |
| `search` | Query | `string` | — | |
| `sortByDate` | Query | `string` | — | |
| `archived` | Query | `string` | — | |
| `builderVersion` | Query | `string` | — | (values: 1, 2) |
| `name` | Query | `string` | — | |
| `parentId` | Query | `string` | — | |
| `originId` | Query | `string` | — | |
| `templatesOnly` | Query | `string` | — | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Success | - **name** (string) (e.g. `New Template`): template name<br> - **updatedBy** (string) (e.g. `John Doe`): updated by<br> - **isPlainText** (boolean) (... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `404` | Not Found | — |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /emails/builder
**Summary:** Create a new template
Create a new template
**Version Header:** `2021-07-28`
**Operation ID:** `create-template`
**Tags:** Templates
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/emails/builder' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/emails/builder', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST builder",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/emails/builder",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **locationId** (string) (required) (e.g. `ve9EPM428h8vShlRW1KT`)
- **title** (string) (e.g. `template title`)
- **type** (string) (required)
- **updatedBy** (string) (e.g. `zYy3YOUuHxgomU1uYJty`)
- **builderVersion** (string)
- **name** (string) (e.g. `Template1`)
- **parentId** (string) (e.g. `zYy3YOUuHxgomU1uYJty`)
- **templateDataUrl** (string) (e.g. ``)
- **importProvider** (string) (required)
- **importURL** (string) (e.g. `https://tplshare.com/fhYJ3Mi`)
- **templateSource** (string) (e.g. `template_library`)
- **isPlainText** (boolean) (e.g. `False`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | Success | - **redirect** (string) (required) (e.g. `66e811229245fc098765590`): template id<br> - **traceId** (string) (required) (e.g. `0c52e980-41f6-4be7-8c... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `404` | Not Found | — |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /emails/builder/data
**Summary:** Update a template
Update a template
**Version Header:** `2021-07-28`
**Operation ID:** `update-template`
**Tags:** Templates
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/emails/builder/data' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/emails/builder/data', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST data",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/emails/builder/data",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **locationId** (string) (required) (e.g. `ve9EPM428h8vShlRW1KT`)
- **templateId** (string) (required) (e.g. `zYy3YOUuHxgomU1uYJty`)
- **updatedBy** (string) (required) (e.g. `zYy3YOUuHxgomU1uYJty`)
- **dnd** (string) (required) (e.g. `{elements:[], attrs:{}, templateSettings:{}}`)
- **html** (string) (required) (e.g. ``)
- **editorType** (string) (required)
- **previewText** (string) (e.g. `zYy3YOUuHxgomU1uYJty`)
- **isPlainText** (boolean) (e.g. `false`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | Success | - **ok** (string) (e.g. `true`): ok<br> - **traceId** (string) (e.g. `0c52e980-41f6-4be7-8c4b-32332ss`): trace id<br> - **previewUrl** (string) (e.g.... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `404` | Not Found | — |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## DELETE /emails/builder/{locationId}/{templateId}
**Summary:** Delete a template
Delete a template
**Version Header:** `2021-07-28`
**Operation ID:** `delete-template`
**Tags:** Templates
### cURL
```bash
curl -X DELETE 'https://rest.gohighlevel.com/v1/emails/builder/VALUE/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('DELETE', '/v1/emails/builder/VALUE/VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - DELETE VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "DELETE",
"url": "https://rest.gohighlevel.com/v1/emails/builder/VALUE/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Path | `string` | ✅ | |
| `templateId` | Path | `string` | ✅ | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Success | - **ok** (string) (e.g. `true`): ok<br> - **traceId** (string) (e.g. `0c52e980-41f6-4be7-8c4b-32332ss`): trace id |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `404` | Not Found | — |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /emails/schedule
**Summary:** Get Campaigns
Get Campaigns
**Version Header:** `2021-07-28`
**Operation ID:** `fetch-campaigns`
**Tags:** Campaigns
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/emails/schedule?locationId=VALUE&limit=VALUE&offset=VALUE&status=active&emailStatus=all&name=VALUE&parentId=VALUE&limitedFields=VALUE&archived=VALUE&campaignsOnly=VALUE&showStats=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/emails/schedule?locationId=VALUE&limit=VALUE&offset=VALUE&status=VALUE&emailStatus=VALUE&name=VALUE&parentId=VALUE&limitedFields=VALUE&archived=VALUE&campaignsOnly=VALUE&showStats=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET schedule",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/emails/schedule",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "offset",
"value": "VALUE"
},
{
"name": "status",
"value": "VALUE"
},
{
"name": "emailStatus",
"value": "VALUE"
},
{
"name": "name",
"value": "VALUE"
},
{
"name": "parentId",
"value": "VALUE"
},
{
"name": "limitedFields",
"value": "VALUE"
},
{
"name": "archived",
"value": "VALUE"
},
{
"name": "campaignsOnly",
"value": "VALUE"
},
{
"name": "showStats",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | Location ID to fetch campaigns from |
| `limit` | Query | `number` | — | Maximum number of campaigns to return. Defaults to 10, maximum is 100 |
| `offset` | Query | `number` | — | Number of campaigns to skip for pagination |
| `status` | Query | `string` | — | Filter by schedule status (values: active, pause, complete, cancelled, retry, draft, resend-sched... |
| `emailStatus` | Query | `string` | — | Filter by email delivery status (values: all, not-started, paused, cancelled, processing, resumed... |
| `name` | Query | `string` | — | Filter campaigns by name |
| `parentId` | Query | `string` | — | Filter campaigns by parent folder ID |
| `limitedFields` | Query | `boolean` | — | When true, returns only essential campaign fields like id, templateDataDownloadUrl, updatedAt, ty... |
| `archived` | Query | `boolean` | — | Filter archived campaigns |
| `campaignsOnly` | Query | `boolean` | — | Return only campaigns, excluding folders |
| `showStats` | Query | `boolean` | — | When true, returns campaign statistics including delivered count, opened count, clicked count and... |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Success | - **schedules** (array of ScheduleDto) (required): The list of campaigns<br> - **total** (array) (required): The total number of campaigns<br> - **tr... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `403` | The token does not have access to this location | - **statusCode** (number) (e.g. `403`)<br> - **message** (string) (e.g. `The token does not have access to this location`) |
| `404` | Not Found | - **statusCode** (number) (e.g. `404`)<br> - **message** (string) (e.g. `Not Found`)<br> - **error** (string) (e.g. `The requested resource was not f... |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
+451
View File
@@ -0,0 +1,451 @@
# GHL Forms API
**Endpoints:** 3
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /forms/](#get--forms-) — Get Forms
- [GET /forms/submissions](#get--forms-submissions) — Get Forms Submissions
- [POST /forms/upload-custom-files](#post--forms-upload-custom-files) — Upload files to custom fields
---
## GET /forms/
**Summary:** Get Forms
Get Forms
**Version Header:** `2021-07-28`
**Operation ID:** `get-forms`
**Tags:** Forms
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/forms/?locationId=VALUE&skip=VALUE&limit=VALUE&type=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/forms/?locationId=VALUE&skip=VALUE&limit=VALUE&type=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET forms",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/forms/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "skip",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "type",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `skip` | Query | `number` | — | |
| `limit` | Query | `number` | — | Limit Per Page records count. will allow maximum up to 50 and default will be 10 |
| `type` | Query | `string` | — | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **forms** (array of FormsParams)<br> - **total** (number) (e.g. `20`): Total number of forms |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /forms/submissions
**Summary:** Get Forms Submissions
Get Forms Submissions
**Version Header:** `2021-07-28`
**Operation ID:** `get-forms-submissions`
**Tags:** Forms
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/forms/submissions?locationId=VALUE&page=VALUE&limit=VALUE&formId=VALUE&q=VALUE&startAt=VALUE&endAt=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/forms/submissions?locationId=VALUE&page=VALUE&limit=VALUE&formId=VALUE&q=VALUE&startAt=VALUE&endAt=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET submissions",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/forms/submissions",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "page",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "formId",
"value": "VALUE"
},
{
"name": "q",
"value": "VALUE"
},
{
"name": "startAt",
"value": "VALUE"
},
{
"name": "endAt",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `page` | Query | `number` | — | Page No. By default it will be 1 |
| `limit` | Query | `number` | — | Limit Per Page records count. will allow maximum up to 100 and default will be 20 |
| `formId` | Query | `string` | — | Filter submission by form id |
| `q` | Query | `string` | — | Filter by contactId, name, email or phone no. |
| `startAt` | Query | `string` | — | Get submission by starting of this date. By default it will be same date of last month(YYYY-MM-DD). |
| `endAt` | Query | `string` | — | Get submission by ending of this date. By default it will be current date(YYYY-MM-DD). |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **submissions** (array of FormsSubmissionsSubmissionsSchema)<br> - **meta** (metaSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /forms/upload-custom-files
**Summary:** Upload files to custom fields
Post the necessary fields for the API to upload files. The files need to be a buffer with the key "< custom_field_id >_< file_id >". <br /> Here custom field id is the ID of your custom field and file id is a randomly generated id (or uuid) <br /> There is support for multiple file uploads as well. Have multiple fields in the format mentioned.<br />File size is limited to 50 MB.<br /><br /> The allowed file types are: <br/> <ul><li>PDF</li><li>DOCX</li><li>DOC</li><li>JPG</li><li>JPEG</li><li>PNG</li><li>GIF</li><li>CSV</li><li>XLSX</li><li>XLS</li><li>MP4</li><li>MPEG</li><li>ZIP</li><li>RAR</li><li>TXT</li><li>SVG</li></ul> <br /><br /> The API will return the updated contact object.
**Version Header:** `2021-07-28`
**Operation ID:** `upload-to-custom-fields`
**Tags:** Forms
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/forms/upload-custom-files?contactId=VALUE&locationId=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('POST', '/v1/forms/upload-custom-files?contactId=VALUE&locationId=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST upload-custom-files",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/forms/upload-custom-files",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "contactId",
"value": "VALUE"
},
{
"name": "locationId",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `contactId` | Query | `string` | ✅ | Contact ID to upload the file to. |
| `locationId` | Query | `string` | ✅ | Location ID of the contact. |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | — |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
+954
View File
@@ -0,0 +1,954 @@
# GHL Funnels API
**Endpoints:** 7
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /funnels/funnel/list](#get--funnels-funnel-list) — Fetch List of Funnels
- [POST /funnels/lookup/redirect](#post--funnels-lookup-redirect) — Create Redirect
- [GET /funnels/lookup/redirect/list](#get--funnels-lookup-redirect-list) — Fetch List of Redirects
- [DELETE /funnels/lookup/redirect/{id}](#delete--funnels-lookup-redirect-id) — Delete Redirect By Id
- [PATCH /funnels/lookup/redirect/{id}](#patch--funnels-lookup-redirect-id) — Update Redirect By Id
- [GET /funnels/page](#get--funnels-page) — Fetch list of funnel pages
- [GET /funnels/page/count](#get--funnels-page-count) — Fetch count of funnel pages
---
## GET /funnels/funnel/list
**Summary:** Fetch List of Funnels
Retrieves a list of all funnels based on the given query parameters.
**Version Header:** Not required
**Operation ID:** `getFunnels`
**Tags:** Funnel
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/funnels/funnel/list?locationId=VALUE&type=VALUE&category=VALUE&offset=VALUE&limit=VALUE&parentId=VALUE&name=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/funnels/funnel/list?locationId=VALUE&type=VALUE&category=VALUE&offset=VALUE&limit=VALUE&parentId=VALUE&name=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET list",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/funnels/funnel/list",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "type",
"value": "VALUE"
},
{
"name": "category",
"value": "VALUE"
},
{
"name": "offset",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "parentId",
"value": "VALUE"
},
{
"name": "name",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `type` | Query | `string` | — | |
| `category` | Query | `string` | — | |
| `offset` | Query | `string` | — | |
| `limit` | Query | `string` | — | |
| `parentId` | Query | `string` | — | |
| `name` | Query | `string` | — | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response - List of funnels returned | - **funnels** (object) (required) (e.g. `{'_id': 'SkIDfu0S4m3NYQyvWHC6', 'dateAdded': '2024-04-29T15:00:05.681Z', 'dateUpdated': '2024-04-29T15:0... |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /funnels/lookup/redirect
**Summary:** Create Redirect
The "Create Redirect" API Allows adding a new url redirect to the system. Use this endpoint to create a url redirect with the specified details. Ensure that the required information is provided in the request payload.
**Version Header:** `2021-07-28`
**Operation ID:** `create-redirect`
**Tags:** Redirect
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/funnels/lookup/redirect' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/funnels/lookup/redirect', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST redirect",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/funnels/lookup/redirect",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **locationId** (string) (required) (e.g. `6p2RxpgtMKQwO3E6IUaT`)
- **domain** (string) (required) (e.g. `example.com`)
- **path** (string) (required) (e.g. `/Hello`)
- **target** (string) (required) (e.g. `https://www.google.com`)
- **action** (string) (required) (e.g. `URL`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **data** (string) (required): Data containing details of the created redirect |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /funnels/lookup/redirect/list
**Summary:** Fetch List of Redirects
Retrieves a list of all URL redirects based on the given query parameters.
**Version Header:** `2021-07-28`
**Operation ID:** `fetch-redirects-list`
**Tags:** Redirect
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/funnels/lookup/redirect/list?locationId=VALUE&limit=VALUE&offset=VALUE&search=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/funnels/lookup/redirect/list?locationId=VALUE&limit=VALUE&offset=VALUE&search=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET list",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/funnels/lookup/redirect/list",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "offset",
"value": "VALUE"
},
{
"name": "search",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `limit` | Query | `number` | ✅ | |
| `offset` | Query | `number` | ✅ | |
| `search` | Query | `string` | — | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response - List of URL redirects returned | - **data** (object) (required) (e.g. `{'count': 42, 'data': []}`): Object containing the count of redirects and an array of redirect data |
| `422` | Unprocessable Entity - The provided data is invalid or incomplete | — |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## DELETE /funnels/lookup/redirect/{id}
**Summary:** Delete Redirect By Id
The "Delete Redirect By Id" API Allows deletion of a URL redirect from the system using its unique identifier. Use this endpoint to delete a URL redirect with the specified ID using details provided in the request payload.
**Version Header:** `2021-07-28`
**Operation ID:** `delete-redirect-by-id`
**Tags:** Redirect
### cURL
```bash
curl -X DELETE 'https://rest.gohighlevel.com/v1/funnels/lookup/redirect/VALUE?locationId=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('DELETE', '/v1/funnels/lookup/redirect/VALUE?locationId=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - DELETE VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "DELETE",
"url": "https://rest.gohighlevel.com/v1/funnels/lookup/redirect/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `id` | Path | `string` | ✅ | |
| `locationId` | Query | `string` | ✅ | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response - URL redirect deleted successfully | - **data** (object) (required) (e.g. `{'status': 'ok'}`): Status of the delete operation |
| `422` | Unprocessable Entity - The provided data is invalid or incomplete | — |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## PATCH /funnels/lookup/redirect/{id}
**Summary:** Update Redirect By Id
The "Update Redirect By Id" API Allows updating an existing URL redirect in the system. Use this endpoint to modify a URL redirect with the specified ID using details provided in the request payload.
**Version Header:** `2021-07-28`
**Operation ID:** `update-redirect-by-id`
**Tags:** Redirect
### cURL
```bash
curl -X PATCH 'https://rest.gohighlevel.com/v1/funnels/lookup/redirect/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('PATCH', '/v1/funnels/lookup/redirect/VALUE', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - PATCH VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "PATCH",
"url": "https://rest.gohighlevel.com/v1/funnels/lookup/redirect/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `id` | Path | `string` | ✅ | |
### Request Body Schema
- **target** (string) (required) (e.g. `https://www.google.com`)
- **action** (string) (required) (e.g. `URL`)
- **locationId** (string) (required) (e.g. `6p2RxpgtMKQwO3E6IUaT`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **data** (string) (required): Data containing details of the updated redirect |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /funnels/page
**Summary:** Fetch list of funnel pages
Retrieves a list of all funnel pages based on the given query parameters.
**Version Header:** Not required
**Operation ID:** `getPagesByFunnelId`
**Tags:** Funnel
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/funnels/page?locationId=VALUE&funnelId=VALUE&name=VALUE&limit=VALUE&offset=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/funnels/page?locationId=VALUE&funnelId=VALUE&name=VALUE&limit=VALUE&offset=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET page",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/funnels/page",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "funnelId",
"value": "VALUE"
},
{
"name": "name",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "offset",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `funnelId` | Query | `string` | ✅ | |
| `name` | Query | `string` | — | |
| `limit` | Query | `number` | ✅ | |
| `offset` | Query | `number` | ✅ | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response - List of funnel pages returned | - **_id** (string) (required) (e.g. `0yJbP3q7t7pLmeTWRAE2`)<br> - **locationId** (string) (required) (e.g. `ojQjykmwNIU88vfsfzvH`)<br> - **funnelId**... |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /funnels/page/count
**Summary:** Fetch count of funnel pages
Retrieves count of all funnel pages based on the given query parameters.
**Version Header:** Not required
**Operation ID:** `getPagesCountByFunnelId`
**Tags:** Funnel
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/funnels/page/count?locationId=VALUE&funnelId=VALUE&name=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/funnels/page/count?locationId=VALUE&funnelId=VALUE&name=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET count",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/funnels/page/count",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
},
{
"name": "funnelId",
"value": "VALUE"
},
{
"name": "name",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
| `funnelId` | Query | `string` | ✅ | |
| `name` | Query | `string` | — | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response - Count of funnel pages returned | - **count** (number) (required) (e.g. `20`) |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
File diff suppressed because it is too large Load Diff
+789
View File
@@ -0,0 +1,789 @@
# GHL Links API
> ⚠️ **Mixed Versions:** Most Links endpoints use `2021-07-28`, but `GET /links/search` uses `Version: 2021-04-15`.
**Endpoints:** 6
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-04-15, 2021-07-28`
---
## Endpoints
- [GET /links/](#get--links-) — Get Links
- [POST /links/](#post--links-) — Create Link
- [GET /links/id/{linkId}](#get--links-id-linkId) — Get Link by ID
- [GET /links/search](#get--links-search) — Search Trigger Links
- [DELETE /links/{linkId}](#delete--links-linkId) — Delete Link
- [PUT /links/{linkId}](#put--links-linkId) — Update Link
---
## GET /links/
**Summary:** Get Links
Get Links
**Version Header:** `2021-07-28`
**Operation ID:** `get-links`
**Tags:** Trigger Links
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/links/?locationId=ve9EPM428h8vShlRW1KT' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/links/?locationId=ve9EPM428h8vShlRW1KT', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET links",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/links/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "ve9EPM428h8vShlRW1KT"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | [example: `ve9EPM428h8vShlRW1KT`] |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **links** (array of LinkSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /links/
**Summary:** Create Link
Create Link
**Version Header:** `2021-07-28`
**Operation ID:** `create-link`
**Tags:** Trigger Links
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/links/' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/links/', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST links",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/links/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **locationId** (string) (required) (e.g. `ve9EPM428h8vShlRW1KT`)
- **name** (string) (required) (e.g. `first tag`)
- **redirectTo** (string) (required) (e.g. `https://www.google.com/`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | Successful response | - **link** (LinkSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /links/id/{linkId}
**Summary:** Get Link by ID
Get a single link by its ID
**Version Header:** `2021-07-28`
**Operation ID:** `get-link-by-id`
**Tags:** Trigger Links
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/links/id/VALUE?locationId=ABCHkzuJQ8ZMd4Te84GK' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/links/id/VALUE?locationId=ABCHkzuJQ8ZMd4Te84GK', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/links/id/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "ABCHkzuJQ8ZMd4Te84GK"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `Authorization` | Header | `string` | ✅ | Access Token [example: `Bearer 9c48df2694a849b6089f9d0d3513efe`] |
| `locationId` | Query | `string` | ✅ | Location Id [example: `ABCHkzuJQ8ZMd4Te84GK`] |
| `linkId` | Path | `string` | ✅ | Link Id |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **link** (LinkSchema) |
| `400` | Bad Request | (ref: #/components/schemas/BadRequestDTO) |
| `401` | Unauthorized | (ref: #/components/schemas/UnauthorizedDTO) |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /links/search
**Summary:** Search Trigger Links
Get list of links by searching
**Version Header:** `2021-04-15`
**Operation ID:** `search-trigger-links`
**Tags:** Trigger Links Search
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/links/search?locationId=ABCHkzuJQ8ZMd4Te84GK&query=Search string&skip=1&limit=10' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-04-15' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-04-15',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/links/search?locationId=ABCHkzuJQ8ZMd4Te84GK&query=Search string&skip=1&limit=10', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET search",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/links/search",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-04-15"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "ABCHkzuJQ8ZMd4Te84GK"
},
{
"name": "query",
"value": "Search string"
},
{
"name": "skip",
"value": 1
},
{
"name": "limit",
"value": 10
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `Authorization` | Header | `string` | ✅ | Access Token [example: `Bearer 9c48df2694a849b6089f9d0d3513efe`] |
| `locationId` | Query | `string` | ✅ | Location Id [example: `ABCHkzuJQ8ZMd4Te84GK`] |
| `query` | Query | `string` | — | Search query as a string [example: `Search string`] |
| `skip` | Query | `number` | — | Numbers of query results to skip [example: `1`] |
| `limit` | Query | `number` | — | Limit on number of search results [example: `10`] |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **links** (array of LinkSchema) |
| `400` | Bad Request | (ref: #/components/schemas/BadRequestDTO) |
| `401` | Unauthorized | (ref: #/components/schemas/UnauthorizedDTO) |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## DELETE /links/{linkId}
**Summary:** Delete Link
Delete Link
**Version Header:** `2021-07-28`
**Operation ID:** `delete-link`
**Tags:** Trigger Links
### cURL
```bash
curl -X DELETE 'https://rest.gohighlevel.com/v1/links/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('DELETE', '/v1/links/VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - DELETE VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "DELETE",
"url": "https://rest.gohighlevel.com/v1/links/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `linkId` | Path | `string` | ✅ | Link Id |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | Successful response | - **succeded** (boolean) (e.g. `True`) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## PUT /links/{linkId}
**Summary:** Update Link
Update Link
**Version Header:** `2021-07-28`
**Operation ID:** `update-link`
**Tags:** Trigger Links
### cURL
```bash
curl -X PUT 'https://rest.gohighlevel.com/v1/links/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('PUT', '/v1/links/VALUE', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - PUT VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "PUT",
"url": "https://rest.gohighlevel.com/v1/links/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `linkId` | Path | `string` | ✅ | Link Id |
### Request Body Schema
- **name** (string) (required) (e.g. `first tag`)
- **redirectTo** (string) (required) (e.g. `https://www.google.com/`)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | Successful response | - **link** (LinkSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
File diff suppressed because it is too large Load Diff
+914
View File
@@ -0,0 +1,914 @@
# GHL Media API
**Endpoints:** 7
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [PUT /medias/delete-files](#put--medias-delete-files) — Bulk Delete / Trash Files or Folders
- [GET /medias/files](#get--medias-files) — Get List of Files/ Folders
- [POST /medias/folder](#post--medias-folder) — Create Folder
- [PUT /medias/update-files](#put--medias-update-files) — Bulk Update Files/ Folders
- [POST /medias/upload-file](#post--medias-upload-file) — Upload File into Media Storage
- [DELETE /medias/{id}](#delete--medias-id) — Delete File or Folder
- [POST /medias/{id}](#post--medias-id) — Update File/ Folder
---
## PUT /medias/delete-files
**Summary:** Bulk Delete / Trash Files or Folders
Soft-deletes or trashes multiple files and folders in a single request
**Version Header:** `2021-07-28`
**Operation ID:** `bulk-delete-media-objects`
**Tags:** Medias
### cURL
```bash
curl -X PUT 'https://rest.gohighlevel.com/v1/medias/delete-files' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('PUT', '/v1/medias/delete-files', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - PUT delete-files",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "PUT",
"url": "https://rest.gohighlevel.com/v1/medias/delete-files",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **filesToBeDeleted** (array of DeleteMediaObjectItem) (required) (e.g. `[{'_id': '686f630df0d3166d68fbcec2'}]`): Array of file objects to be deleted or trashed
- **altType** (string) (required) (e.g. `location`): Type of entity that owns the files
- **altId** (string) (required) (e.g. `sx6wyHhbFdRXh302LLNR`): Location identifier
- **status** (string) (required) (e.g. `deleted`): Status to set for the files (deleted or trashed)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | object — no documented properties |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /medias/files
**Summary:** Get List of Files/ Folders
Fetches list of files and folders from the media storage
**Version Header:** `2021-07-28`
**Operation ID:** `fetch-media-content`
**Tags:** Medias
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/medias/files?offset=VALUE&limit=VALUE&sortBy=VALUE&sortOrder=VALUE&type=VALUE&query=VALUE&altType=location&altId=VALUE&parentId=VALUE&fetchAll=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/medias/files?offset=VALUE&limit=VALUE&sortBy=VALUE&sortOrder=VALUE&type=VALUE&query=VALUE&altType=VALUE&altId=VALUE&parentId=VALUE&fetchAll=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET files",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/medias/files",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "offset",
"value": "VALUE"
},
{
"name": "limit",
"value": "VALUE"
},
{
"name": "sortBy",
"value": "VALUE"
},
{
"name": "sortOrder",
"value": "VALUE"
},
{
"name": "type",
"value": "VALUE"
},
{
"name": "query",
"value": "VALUE"
},
{
"name": "altType",
"value": "VALUE"
},
{
"name": "altId",
"value": "VALUE"
},
{
"name": "parentId",
"value": "VALUE"
},
{
"name": "fetchAll",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `offset` | Query | `string` | — | Number of files to skip in listing |
| `limit` | Query | `string` | — | Number of files to show in the listing |
| `sortBy` | Query | `string` | ✅ | Field to sorting the file listing by |
| `sortOrder` | Query | `string` | ✅ | Direction in which file needs to be sorted |
| `type` | Query | `string` | ✅ | Type |
| `query` | Query | `string` | — | Query text |
| `altType` | Query | `string` | ✅ | AltType (values: location) |
| `altId` | Query | `string` | ✅ | location Id |
| `parentId` | Query | `string` | — | parent id or folder id |
| `fetchAll` | Query | `string` | — | Fetch all files or folders |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **files** (array) (required) (e.g. `{'altId': 'locationId', 'altType': 'location', 'name': 'file name', 'parentId': 'parent folder id', 'url': ... |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /medias/folder
**Summary:** Create Folder
Creates a new folder in the media storage
**Version Header:** `2021-07-28`
**Operation ID:** `create-media-folder`
**Tags:** Medias
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/medias/folder' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/medias/folder', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST folder",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/medias/folder",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **altId** (string) (required) (e.g. `sx6wyHhbFdRXh302LLNR`): Location Id
- **altType** (string) (required) (e.g. `location`): Type of entity (location only)
- **name** (string) (required) (e.g. `New Folder`): Name of the folder to be created
- **parentId** (string) (e.g. `64af50c42d567a3b4f5989e0`): ID of the parent folder (optional)
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Returns the newly created folder object | - **altId** (string) (required) (e.g. `sx6wyHhbFdRXh302LLNR`): Location identifier that owns this folder<br> - **altType** (string) (required) (e.g... |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## PUT /medias/update-files
**Summary:** Bulk Update Files/ Folders
Updates metadata or status of multiple files and folders
**Version Header:** `2021-07-28`
**Operation ID:** `bulk-update-media-objects`
**Tags:** Medias
### cURL
```bash
curl -X PUT 'https://rest.gohighlevel.com/v1/medias/update-files' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('PUT', '/v1/medias/update-files', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - PUT update-files",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "PUT",
"url": "https://rest.gohighlevel.com/v1/medias/update-files",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **altId** (string) (required) (e.g. `sx6wyHhbFdRXh302LLNR`): Location identifier
- **altType** (string) (required) (e.g. `location`): Type of entity that owns the files
- **filesToBeUpdated** (array of UpdateMediaObject) (required) (e.g. `[{'id': '686f9817f0d3165be9fbcef6', 'name': 'Updated File Name.pdf'}]`): Array of file objects to be updated
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | object — no documented properties |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /medias/upload-file
**Summary:** Upload File into Media Storage
If hosted is set to true then fileUrl is required. Else file is required. If adding a file, maximum allowed is 25 MB
**Version Header:** `2021-07-28`
**Operation ID:** `upload-media-content`
**Tags:** Medias
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/medias/upload-file' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('POST', '/v1/medias/upload-file', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST upload-file",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/medias/upload-file",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **fileId** (string) (required) (e.g. `file.pdf`): ID of the uploaded file<br> - **url** (string) (required) (e.g. `https://storage.googleapis.com... |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## DELETE /medias/{id}
**Summary:** Delete File or Folder
Deletes specific file or folder from the media storage
**Version Header:** `2021-07-28`
**Operation ID:** `delete-media-content`
**Tags:** Medias
### cURL
```bash
curl -X DELETE 'https://rest.gohighlevel.com/v1/medias/VALUE?altType=location&altId=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('DELETE', '/v1/medias/VALUE?altType=VALUE&altId=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - DELETE VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "DELETE",
"url": "https://rest.gohighlevel.com/v1/medias/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "altType",
"value": "VALUE"
},
{
"name": "altId",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `id` | Path | `string` | ✅ | |
| `altType` | Query | `string` | ✅ | AltType (values: location) |
| `altId` | Query | `string` | ✅ | location Id |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | — |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /medias/{id}
**Summary:** Update File/ Folder
Updates a single file or folder by ID
**Version Header:** `2021-07-28`
**Operation ID:** `update-media-object`
**Tags:** Medias
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/medias/VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/medias/VALUE', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST VALUE",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/medias/VALUE",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `id` | Path | `string` | ✅ | Unique identifier of the file or folder to update |
### Request Body Schema
- **name** (string) (required) (e.g. `Updated File Name.pdf`): New name for the file or folder
- **altType** (string) (required) (e.g. `location`): Type of entity that owns the file or folder
- **altId** (string) (required) (e.g. `sx6wyHhbFdRXh302LLNR`): Location identifier that owns the file or folder
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | object — no documented properties |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
+412
View File
@@ -0,0 +1,412 @@
# GHL OAuth API
**Endpoints:** 3
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /oauth/installedLocations](#get--oauth-installedLocations) — Get Location where app is installed
- [POST /oauth/locationToken](#post--oauth-locationToken) — Get Location Access Token from Agency Token
- [POST /oauth/token](#post--oauth-token) — Get Access Token
---
## GET /oauth/installedLocations
**Summary:** Get Location where app is installed
This API allows you fetch location where app is installed upon
**Version Header:** `2021-07-28`
**Operation ID:** `get-installed-location`
**Tags:** OAuth 2.0
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/oauth/installedLocations?skip=1&limit=10&query=location name&isInstalled=True&companyId=tDtDnQdgm2LXpyiqYvZ6&appId=tDtDnQdgm2LXpyiqYvZ6&versionId=tDtDnQdgm2LXpyiqYvZ6&onTrial=True&planId=True&locationId=1245' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/oauth/installedLocations?skip=1&limit=10&query=location name&isInstalled=True&companyId=tDtDnQdgm2LXpyiqYvZ6&appId=tDtDnQdgm2LXpyiqYvZ6&versionId=tDtDnQdgm2LXpyiqYvZ6&onTrial=True&planId=True&locationId=1245', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET installedLocations",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/oauth/installedLocations",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "skip",
"value": "1"
},
{
"name": "limit",
"value": "10"
},
{
"name": "query",
"value": "location name"
},
{
"name": "isInstalled",
"value": true
},
{
"name": "companyId",
"value": "tDtDnQdgm2LXpyiqYvZ6"
},
{
"name": "appId",
"value": "tDtDnQdgm2LXpyiqYvZ6"
},
{
"name": "versionId",
"value": "tDtDnQdgm2LXpyiqYvZ6"
},
{
"name": "onTrial",
"value": true
},
{
"name": "planId",
"value": true
},
{
"name": "locationId",
"value": "1245"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `skip` | Query | `string` | — | Parameter to skip the number installed locations [example: `1`] |
| `limit` | Query | `string` | — | Parameter to limit the number installed locations [example: `10`] |
| `query` | Query | `string` | — | Parameter to search for the installed location by name [example: `location name`] |
| `isInstalled` | Query | `boolean` | — | Filters out location which are installed for specified app under the specified company [example: ... |
| `companyId` | Query | `string` | ✅ | Parameter to search by the companyId [example: `tDtDnQdgm2LXpyiqYvZ6`] |
| `appId` | Query | `string` | ✅ | Parameter to search by the appId [example: `tDtDnQdgm2LXpyiqYvZ6`] |
| `versionId` | Query | `string` | — | VersionId of the app [example: `tDtDnQdgm2LXpyiqYvZ6`] |
| `onTrial` | Query | `boolean` | — | Filters out locations which are installed for specified app in trial mode [example: `True`] |
| `planId` | Query | `string` | — | Filters out location which are installed for specified app under the specified planId [example: `... |
| `locationId` | Query | `string` | — | locationId [example: `1245`] |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **locations** (array of InstalledLocationSchema)<br> - **count** (number) (e.g. `1231`): Total location count under the company<br> - **installToFu... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /oauth/locationToken
**Summary:** Get Location Access Token from Agency Token
This API allows you to generate locationAccessToken from AgencyAccessToken
**Version Header:** `2021-07-28`
**Operation ID:** `get-location-access-token`
**Tags:** OAuth 2.0
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/oauth/locationToken' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('POST', '/v1/oauth/locationToken', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST locationToken",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/oauth/locationToken",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **access_token** (string) (e.g. `ab12dc0ae1234a7898f9ff06d4f69gh`): Location access token which can be used to authenticate & authorize API und... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /oauth/token
**Summary:** Get Access Token
Use Access Tokens to access GoHighLevel resources on behalf of an authenticated location/company.
**Version Header:** Not required
**Operation ID:** `get-access-token`
**Tags:** OAuth 2.0
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/oauth/token' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('POST', '/v1/oauth/token', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST token",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/oauth/token",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **access_token** (string) (e.g. `ab12dc0ae1234a7898f9ff06d4f69gh`)<br> - **token_type** (string) (e.g. `Bearer`)<br> - **expires_in** (number) (e.g... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+958
View File
@@ -0,0 +1,958 @@
# GHL Users API
> ⚠️ **Deprecations:** Email update is deprecated. User deletion is asynchronous (returns 202 Accepted). `GET /users/` is deprecated — use `GET /users/search` instead.
**Endpoints:** 7
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /users/](#get--users-) — Get User by Location
- [POST /users/](#post--users-) — Create User
- [GET /users/search](#get--users-search) — Search Users
- [POST /users/search/filter-by-email](#post--users-search-filter-by-email) — Filter Users by Email
- [DELETE /users/{userId}](#delete--users-userId) — Delete User
- [GET /users/{userId}](#get--users-userId) — Get User
- [PUT /users/{userId}](#put--users-userId) — Update User
---
## GET /users/
> ⚠️ **DEPRECATED** — This endpoint is deprecated and may be removed in a future version.
**Summary:** Get User by Location
Deprecated. Use `GET /users/search` instead. Pass `locationId` as a query parameter to filter results by location, along with the required `companyId` and other search filters as needed.
**Version Header:** `2021-07-28`
**Operation ID:** `get-user-by-location`
**Tags:** Users
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/users/?locationId=s4BtzHFWmT28mbb85uPa' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/users/?locationId=s4BtzHFWmT28mbb85uPa', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET users",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/users/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "s4BtzHFWmT28mbb85uPa"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | [example: `s4BtzHFWmT28mbb85uPa`] |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **users** (array of UserSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /users/
**Summary:** Create User
Create User
**Version Header:** `2021-07-28`
**Operation ID:** `create-user`
**Tags:** Users
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/users/' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/users/', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST users",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/users/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **companyId** (string) (required) (e.g. `ve9EPM428h8vShlRW1KT`)
- **firstName** (string) (required) (e.g. `John`)
- **lastName** (string) (required) (e.g. `Deo`)
- **email** (string) (required) (e.g. `john@deo.com`)
- **password** (string) (required) (e.g. `*******`)
- **phone** (string) (e.g. `+18832327657`)
- **type** (string) (required) (e.g. `account`)
- **role** (string) (required) (e.g. `admin`)
- **locationIds** (array) (required) (e.g. `['C2QujeCh8ZnC7al2InWR']`)
- **permissions** (PermissionsDto)
- **scopes** (array) (e.g. `['contacts.write', 'campaigns.readonly']`): Scopes allowed for users. Only scopes that have been passed will be enabled. Note:- If passed empty all the scopes will be get disabled
- **scopesAssignedToOnly** (array) (e.g. `['contacts.write', 'campaigns.readonly']`): Assigned Scopes allowed for users. Only scopes that have been passed will be enabled. If passed empty all the assigned scopes will be get disabled
- **profilePhoto** (string) (e.g. `https://img.png`)
- **twilioPhone** (object) (e.g. `{'C2QujeCh8ZnC7al2InWR': '+18832327657', 'M2QrtfVt8ZnC7cv2InDL': '+18832327657'}`): Per-location inbound Twilio number in E.164 format, keyed by location id (Call and Voicemail Inbound Number for direct Twilio, not LC Phone). Replacement semantics: if you send twilioPhone in the request body, the stored map is replaced entirely with this object (not merged). Any location id omitted from the object is removed from the saved map. Omit the twilioPhone property entirely to leave existing numbers unchanged. Send an empty object {} to clear all per-location numbers. To clear a single location only, set that location id to an empty string "".
- **platformLanguage** (string) (e.g. `en_US`): Platform language preference for the user
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `201` | Successful response | - **id** (string) (e.g. `0IHuJvc2ofPAAA8GzTRi`)<br> - **name** (string) (e.g. `John Deo`)<br> - **firstName** (string) (e.g. `John`)<br> - **lastName**... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## GET /users/search
**Summary:** Search Users
Search Users
**Version Header:** `2021-07-28`
**Operation ID:** `search-users`
**Tags:** Search
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/users/search?companyId=5DP41231LkQsiKESj6rh&query=John&skip=1&limit=10&locationId=5DP41231LkQsiKESj6rh&type=agency&role=admin&ids=5DP4iH6HLkQsiKESj6rh,5DP4iH6HLkQsiKESj34h&sort=dateAdded&sortDirection=asc&enabled2waySync=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/users/search?companyId=5DP41231LkQsiKESj6rh&query=John&skip=1&limit=10&locationId=5DP41231LkQsiKESj6rh&type=agency&role=admin&ids=5DP4iH6HLkQsiKESj6rh,5DP4iH6HLkQsiKESj34h&sort=dateAdded&sortDirection=asc&enabled2waySync=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET search",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/users/search",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "companyId",
"value": "5DP41231LkQsiKESj6rh"
},
{
"name": "query",
"value": "John"
},
{
"name": "skip",
"value": "1"
},
{
"name": "limit",
"value": "10"
},
{
"name": "locationId",
"value": "5DP41231LkQsiKESj6rh"
},
{
"name": "type",
"value": "agency"
},
{
"name": "role",
"value": "admin"
},
{
"name": "ids",
"value": "5DP4iH6HLkQsiKESj6rh,5DP4iH6HLkQsiKESj34h"
},
{
"name": "sort",
"value": "dateAdded"
},
{
"name": "sortDirection",
"value": "asc"
},
{
"name": "enabled2waySync",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `companyId` | Query | `string` | ✅ | Company ID in which the search needs to be performed [example: `5DP41231LkQsiKESj6rh`] |
| `query` | Query | `string` | — | The search term for the user is matched based on the user full name, email or phone [example: `Jo... |
| `skip` | Query | `string` | — | No of results to be skipped before returning the result [example: `1`] |
| `limit` | Query | `string` | — | No of results to be limited before returning the result [example: `10`] |
| `locationId` | Query | `string` | — | Location ID in which the search needs to be performed [example: `5DP41231LkQsiKESj6rh`] |
| `type` | Query | `string` | — | Type of the users to be filtered in the search [example: `agency`] |
| `role` | Query | `string` | — | Role of the users to be filtered in the search [example: `admin`] |
| `ids` | Query | `string` | — | List of User IDs to be filtered in the search [example: `5DP4iH6HLkQsiKESj6rh,5DP4iH6HLkQsiKESj34h`] |
| `sort` | Query | `string` | — | The field on which sort is applied in which the results need to be sorted. Default is based on th... |
| `sortDirection` | Query | `string` | — | The direction in which the results need to be sorted [example: `asc`] |
| `enabled2waySync` | Query | `boolean` | — | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **users** (array of UserSchema)<br> - **count** (number) (e.g. `1231`) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## POST /users/search/filter-by-email
**Summary:** Filter Users by Email
Filter users by company ID, deleted status, and email array
**Version Header:** `2021-07-28`
**Operation ID:** `filter-users-by-email`
**Tags:** Search
### cURL
```bash
curl -X POST 'https://rest.gohighlevel.com/v1/users/search/filter-by-email' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('POST', '/v1/users/search/filter-by-email', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - POST filter-by-email",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "POST",
"url": "https://rest.gohighlevel.com/v1/users/search/filter-by-email",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **companyId** (string) (required) (e.g. `5DP41231LkQsiKESj6rh`): Company ID to filter users
- **emails** (string) (required) (e.g. `user1@example.com,user2@example.com`): Comma-separated list of email addresses to filter users
- **deleted** (boolean) (e.g. `False`): Filter deleted users
- **skip** (string) (e.g. `1`): No of results to be skipped before returning the result
- **limit** (string) (e.g. `10`): No of results to be limited before returning the result
- **projection** (string) (e.g. `all`): Projection fields to return. Use "all" for all fields, or specify comma-separated field names. Default returns only id and email
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **users** (array of UserSchema)<br> - **count** (number) (e.g. `1231`) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
> 📌 **POST for Search:** Search endpoints use POST instead of GET because the search filters are complex JSON objects that can't be easily encoded in query parameters.
---
## DELETE /users/{userId}
**Summary:** Delete User
Delete User
**Version Header:** `2021-07-28`
**Operation ID:** `delete-user`
**Tags:** Users
### cURL
```bash
curl -X DELETE 'https://rest.gohighlevel.com/v1/users/{userId}' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('DELETE', '/v1/users/{userId}', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - DELETE {userId}",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "DELETE",
"url": "https://rest.gohighlevel.com/v1/users/{userId}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **succeded** (boolean) (e.g. `True`)<br> - **message** (string) (e.g. `Queued deleting user with e-mail john@deo.com and name John Deo. Will take... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
> ⚠️ **Async Delete:** User deletion is asynchronous. The API returns `202 Accepted` but the user may not be deleted immediately.
---
## GET /users/{userId}
**Summary:** Get User
Get User
**Version Header:** `2021-07-28`
**Operation ID:** `get-user`
**Tags:** Users
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/users/ve9EPM428h8vShlRW1KT' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/users/ve9EPM428h8vShlRW1KT', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET ve9EPM428h8vShlRW1KT",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/users/ve9EPM428h8vShlRW1KT",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `userId` | Path | `string` | ✅ | User Id [example: `ve9EPM428h8vShlRW1KT`] |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **id** (string) (e.g. `0IHuJvc2ofPAAA8GzTRi`)<br> - **name** (string) (e.g. `John Deo`)<br> - **firstName** (string) (e.g. `John`)<br> - **lastName**... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
## PUT /users/{userId}
**Summary:** Update User
Update User
**Version Header:** `2021-07-28`
**Operation ID:** `update-user`
**Tags:** Users
### cURL
```bash
curl -X PUT 'https://rest.gohighlevel.com/v1/users/{userId}' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0' \
-d '{}' # See request body schema below
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
payload = json.dumps({})
conn.request('PUT', '/v1/users/{userId}', body=payload, headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - PUT {userId}",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "PUT",
"url": "https://rest.gohighlevel.com/v1/users/{userId}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": []
},
"options": {}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Body Schema
- **firstName** (string) (e.g. `John`)
- **lastName** (string) (e.g. `Deo`)
- **email** (string) (e.g. `john@deo.com`): Email update is no longer supported due to security reasons.
- **password** (string) (e.g. `*******`)
- **phone** (string) (e.g. `+18832327657`)
- **type** (string) (e.g. `account`)
- **role** (string) (e.g. `admin`)
- **companyId** (string) (e.g. `UAXssdawIWAWD`): Company/Agency Id. Required for Agency Level access
- **locationIds** (array) (e.g. `['C2QujeCh8ZnC7al2InWR']`)
- **permissions** (PermissionsDto)
- **scopes** (array) (e.g. `['contacts.write', 'campaigns.readonly']`): Scopes allowed for users. Only scopes that have been passed will be enabled. If passed empty all the scopes will be get disabled
- **scopesAssignedToOnly** (array) (e.g. `['contacts.write', 'campaigns.readonly']`): Assigned Scopes allowed for users. Only scopes that have been passed will be enabled. If passed empty all the assigned scopes will be get disabled
- **profilePhoto** (string) (e.g. `https://img.png`)
- **twilioPhone** (object) (e.g. `{'C2QujeCh8ZnC7al2InWR': '+18832327657', 'M2QrtfVt8ZnC7cv2InDL': '+18832327657'}`): Per-location inbound Twilio number in E.164 format, keyed by location id (Call and Voicemail Inbound Number for direct Twilio, not LC Phone). Replacement semantics: if you send twilioPhone in the request body, the stored map is replaced entirely with this object (not merged). Any location id omitted from the object is removed from the saved map. Omit the twilioPhone property entirely to leave existing numbers unchanged. Send an empty object {} to clear all per-location numbers. To clear a single location only, set that location id to an empty string "".
- **platformLanguage** (string) (e.g. `en_US`): Platform language preference for the user
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **id** (string) (e.g. `0IHuJvc2ofPAAA8GzTRi`)<br> - **name** (string) (e.g. `John Deo`)<br> - **firstName** (string) (e.g. `John`)<br> - **lastName**... |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
+145
View File
@@ -0,0 +1,145 @@
# GHL Workflows API
**Endpoints:** 1
**Base URL:** `https://rest.gohighlevel.com/v1`
**Version Header(s):** `2021-07-28`
---
## Endpoints
- [GET /workflows/](#get--workflows-) — Get Workflow
---
## GET /workflows/
**Summary:** Get Workflow
Get Workflow
**Version Header:** `2021-07-28`
**Operation ID:** `get-workflow`
**Tags:** Workflows
### cURL
```bash
curl -X GET 'https://rest.gohighlevel.com/v1/workflows/?locationId=VALUE' \
-H 'Authorization: Bearer YOUR_API_TOKEN' \
-H 'Version: 2021-07-28' \
-H 'Content-Type: application/json' \
-H 'Accept: application/json' \
-H 'User-Agent: YourApp/1.0'
```
### Python (http.client)
```python
import http.client
import json
conn = http.client.HTTPSConnection('rest.gohighlevel.com')
headers = {
'Authorization': 'Bearer YOUR_API_TOKEN',
'Version': '2021-07-28',
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'YourApp/1.0',
}
conn.request('GET', '/v1/workflows/?locationId=VALUE', headers=headers)
response = conn.getresponse()
data = json.loads(response.read().decode())
print(json.dumps(data, indent=2))
conn.close()
```
### n8n HTTP Node
```json
{
"name": "GHL - GET workflows",
"type": "n8n-nodes-base.httpRequest",
"parameters": {
"method": "GET",
"url": "https://rest.gohighlevel.com/v1/workflows/",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "Bearer YOUR_API_TOKEN"
},
{
"name": "Version",
"value": "2021-07-28"
},
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "Accept",
"value": "application/json"
},
{
"name": "User-Agent",
"value": "YourApp/1.0"
}
]
},
"sendBody": false,
"bodyParameters": {
"parameters": []
},
"options": {},
"sendQuery": true,
"queryParameters": {
"parameters": [
{
"name": "locationId",
"value": "VALUE"
}
]
}
}
}
```
**To use in n8n:**
1. Add an HTTP Request node
2. Switch to JSON mode (Parameters → use RAW JSON)
3. Paste the JSON above
4. Update `YOUR_API_TOKEN` with your actual GHL API key
### Request Parameters
| Name | Location | Type | Required | Description |
|------|----------|------|----------|-------------|
| `locationId` | Query | `string` | ✅ | |
### Response Codes
| Code | Description | Schema |
|------|-------------|--------|
| `200` | Successful response | - **workflows** (array of WorkflowSchema) |
| `400` | Bad Request | Standard error response for bad requests (400). Contains message, statusCode fields. |
| `401` | Unauthorized | Standard error response for unauthorized requests (401). Contains message, statusCode fields. |
| `422` | Unprocessable Entity | Standard error response for unprocessable entity (422). Contains message, statusCode, errors array fields. |
### ⚠️ Special Notes & Quirks
> ⚠️ **User-Agent Required:** All GHL API requests require a `User-Agent` HTTP header. Requests without it may be rejected with a 403 error.
---
*Documentation generated from GHL OpenAPI specifications. Verify against official docs for latest changes.*
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
Dentro de n8n para poder utilizar la Verificador de sucursales y poder obtener un listado de Los datos de acceso utilizamos una instancia de baserow donde esta cargada la información que se usa.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+93
View File
@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""Agrega al workflow n8n de oportunidades un nodo HTTP PUT que, antes de
'Buscar Oportunidad - SUCURSAL', setea el campo 'ID Oportunidad Sucursal' de la
opp de sucursal = su propio id nativo (del webhook). Reordena la rama sucursal
para que 'Conseguir Custom Fields - Opportunity - SUCURSAL' vaya antes (necesario
para resolver el field_id, que varia por sucursal). Hace backup y verifica.
"""
import requests, json, datetime, uuid
api_key = base = None
with open('n8n/n8n credencials.txt', encoding='utf-8') as f:
for line in f:
if line.startswith('API:'): api_key = line.split('API:', 1)[1].strip()
if line.startswith('URL:'): base = line.split('URL:', 1)[1].strip().rstrip('/')
H = {'X-N8N-API-KEY': api_key, 'Accept': 'application/json', 'Content-Type': 'application/json'}
WID = 'Cfgwp0bOtDW8zuKW'
wf = requests.get(f'{base}/api/v1/workflows/{WID}', headers=H, timeout=30).json()
print('versionId previo:', wf.get('versionId'))
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
bpath = f'n8n/backup_workflow_pre_automap_{ts}.json'
open(bpath, 'w', encoding='utf-8').write(json.dumps(wf, ensure_ascii=False, indent=2))
print('backup:', bpath)
nodes = wf['nodes']; conns = wf['connections']
NEW_NAME = 'Mapear ID Oportunidad Sucursal - SUCURSAL'
if any(n.get('name') == NEW_NAME for n in nodes):
print('El nodo ya existe; abortando para no duplicar.'); raise SystemExit
jsonBody = ("={{ JSON.stringify({ customFields: [ { "
"id: $('Conseguir Custom Fields - Opportunity - SUCURSAL').item.json.customFields"
".find(f => f.fieldKey === 'opportunity.id_oportunidad_sucursal')?.id, "
"key: 'opportunity.id_oportunidad_sucursal', "
"field_value: $('Datos de Lead').item.json.Oportunidad.opportunity_id } ] }) }}")
new_node = {
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/opportunities/{{ $('Datos de Lead').item.json.Oportunidad.opportunity_id }}",
"sendHeaders": True,
"headerParameters": {"parameters": [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization", "value": "=Bearer {{ $('DATOS API - SUCURSAL').item.json['Token/API'] }}"},
{"name": "Content-Type", "value": "application/json"},
]},
"sendBody": True,
"specifyBody": "json",
"jsonBody": jsonBody,
"options": {},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1448, 176],
"id": str(uuid.uuid4()),
"name": NEW_NAME,
"onError": "continueRegularOutput",
"notes": ("Auto-mapeo de seguridad: setea 'ID Oportunidad Sucursal' = id nativo "
"de la propia opp de sucursal (del webhook) antes de procesar. "
"onError=continue para no romper el sync si falla."),
}
nodes.append(new_node)
def setconn(src, dst):
conns[src] = {'main': [[{'node': dst, 'type': 'main', 'index': 0}]]}
# Rama sucursal reordenada: DATOS API - SUCURSAL -> Conseguir CF -> [PUT] -> Buscar Opp -> Obtener info
setconn('DATOS API - SUCURSAL', 'Conseguir Custom Fields - Opportunity - SUCURSAL')
setconn('Conseguir Custom Fields - Opportunity - SUCURSAL', NEW_NAME)
setconn(NEW_NAME, 'Buscar Oportunidad - SUCURSAL')
setconn('Buscar Oportunidad - SUCURSAL', 'Obtener info de Oportunidad - SUCURSAL')
ALLOWED = {'saveExecutionProgress', 'saveManualExecutions', 'saveDataErrorExecution',
'saveDataSuccessExecution', 'executionTimeout', 'errorWorkflow', 'timezone', 'executionOrder'}
settings = {k: v for k, v in (wf.get('settings') or {}).items() if k in ALLOWED}
payload = {'name': wf['name'], 'nodes': nodes, 'connections': conns, 'settings': settings}
r = requests.put(f'{base}/api/v1/workflows/{WID}', headers=H, data=json.dumps(payload), timeout=60)
print('PUT status:', r.status_code)
if r.status_code != 200:
print(r.text[:600]); raise SystemExit('PUT fallo')
wf2 = requests.get(f'{base}/api/v1/workflows/{WID}', headers=H, timeout=30).json()
print('versionId nuevo:', wf2.get('versionId'))
print('active:', wf2.get('active'))
print('total nodos:', len(wf2['nodes']))
print('nodo agregado:', any(n.get('name') == NEW_NAME for n in wf2['nodes']))
c = wf2['connections']
def tgt(s): return [x['node'] for o in c.get(s, {}).get('main', []) for x in (o or [])]
print('DATOS API - SUCURSAL ->', tgt('DATOS API - SUCURSAL'))
print('Conseguir CF SUCURSAL ->', tgt('Conseguir Custom Fields - Opportunity - SUCURSAL'))
print(NEW_NAME, '->', tgt(NEW_NAME))
print('Buscar Oportunidad - SUCURSAL ->', tgt('Buscar Oportunidad - SUCURSAL'))
+201
View File
@@ -0,0 +1,201 @@
# -*- coding: utf-8 -*-
"""Endurece el workflow de sync de opps (Cfgwp0bOtDW8zuKW) con idempotencia
GLOBAL via mapeo Baserow, para que NO vuelva a crear replicas duplicadas en
Marca cuando la identidad del contacto es ambigua.
Problema (caso 2026-05-30): el nodo 'Decidir Match (Create vs Update)' busca la
opp existente SOLO entre las opps del contacto resuelto ese run. Si resuelve un
contacto distinto al de la opp original (mismo telefono/nombre variante), no la
encuentra -> CREATE -> duplicado (descuadre positivo Marca>Sucursales).
Fix:
1. Nuevo nodo Baserow 'Buscar Mapeo Opp - Baserow' (getAll, tabla 754,
filtro id_opp_sucursal == id de la opp de sucursal). alwaysOutputData=true
+ onError=continueRegularOutput (si falla o no hay match, sigue el flujo).
2. Se inserta EN SERIE: Set Contact ID Resuelto -> [Buscar Mapeo Opp] ->
Buscar Oportunidades del Contacto - MARCA -> Decidir Match. Las refs por
nombre ($('NodoX')) de los nodos siguientes se mantienen intactas.
3. 'Decidir Match' reescrito: si el mapeo Baserow tiene id_opp_marca -> UPDATE
esa opp (global, independiente del contacto); si no, cae al match por CF
entre las opps del contacto (logica previa); si nada -> CREATE.
La frescura del mapeo (opps nuevas) la cubre el backfill idempotente agendado
(scripts/backfill_baserow_opp_mapping.py --table-id 754), no un nodo create
fragil dentro del flujo de produccion.
Dry-run por defecto (dump a n8n/dryrun_*.json); --apply para PUT real.
"""
import argparse
import os
import sys
import uuid
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.n8n_workflow_lib import load_credentials, N8NClient
WID = "Cfgwp0bOtDW8zuKW"
BASEROW_DB = 63
MAPPING_TABLE = 754
FIELD_ID_OPP_SUCURSAL = 7280 # campo "id_opp_sucursal" (primario) en tabla 754
BASEROW_CRED = {"baserowApi": {"id": "LZztQ3WMpzXjSTIH", "name": "Baserow account"}}
NAME_LOOKUP = "Buscar Mapeo Opp - Baserow"
SRC = "Set Contact ID Resuelto"
NEXT = "Buscar Oportunidades del Contacto - MARCA"
DECIDE = "Decidir Match (Create vs Update)"
NEW_DECIDE_CODE = """// DECISION (Create vs Update) con idempotencia GLOBAL via mapeo Baserow (tabla 754).
// (1) Si el mapeo id_opp_sucursal -> id_opp_marca existe -> UPDATE esa opp de Marca,
// independiente del contacto resuelto (evita duplicados por contacto ambiguo).
// (2) Si no hay mapeo, match por CF entre las opps del contacto resuelto.
// (3) Si nada -> CREATE.
const result = $input.first().json;
const opportunities = result.opportunities || [];
const sucursalOppId = $('Datos de Lead').first().json.Oportunidad.opportunity_id;
let action = 'CREATE';
let opportunityId = null;
let matchReason = '';
// (1) Mapeo Baserow global
let baserowRows = [];
try { baserowRows = $('Buscar Mapeo Opp - Baserow').all().map(i => i.json); } catch (e) { baserowRows = []; }
const mapped = baserowRows.find(r =>
String(r.id_opp_sucursal || '') === String(sucursalOppId) && String(r.id_opp_marca || '').trim()
);
if (mapped) {
action = 'UPDATE';
opportunityId = String(mapped.id_opp_marca).trim();
matchReason = 'Match por mapeo Baserow (global, tabla 754)';
} else {
// (2) match por CF entre las opps del contacto resuelto
const match = opportunities.find(o =>
(o.customFields || []).some(cf => {
const v = cf.fieldValueString ?? cf.fieldValue ?? '';
return String(v) === String(sucursalOppId);
})
);
if (match) {
action = 'UPDATE';
opportunityId = match.id;
matchReason = 'Match por ID Oportunidad Sucursal (contacto)';
} else {
action = 'CREATE';
matchReason = 'Sin match (Baserow ni contacto) -> CREATE (contacto tiene ' + opportunities.length + ' opps)';
}
}
return [{
json: {
action,
opportunityId,
matchReason,
sucursalOppId,
contactId: $('Set Contact ID Resuelto').first().json.contactId,
totalOppsContacto: opportunities.length
}
}];
"""
def build_lookup_node():
return {
"parameters": {
"databaseId": BASEROW_DB,
"tableId": MAPPING_TABLE,
"limit": 1,
"additionalOptions": {
"filters": {
"fields": [
{
"field": FIELD_ID_OPP_SUCURSAL,
"value": "={{ $('Datos de Lead').item.json.Oportunidad.opportunity_id }}",
}
]
}
},
},
"type": "n8n-nodes-base.baserow",
"typeVersion": 1.1,
"position": [4200, 1140],
"id": str(uuid.uuid4()),
"name": NAME_LOOKUP,
"credentials": BASEROW_CRED,
"alwaysOutputData": True,
"onError": "continueRegularOutput",
"notes": (
"Idempotencia global: busca en la tabla 754 el mapeo id_opp_sucursal->"
"id_opp_marca. Si existe, 'Decidir Match' hara UPDATE de esa opp aunque "
"el contacto resuelto sea otro. alwaysOutputData + onError para no romper "
"el flujo si no hay match o si Baserow falla."
),
}
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Aplicar PUT real (default: dry-run a n8n/dryrun_*.json)")
return ap.parse_args()
def main():
args = parse_args()
client = N8NClient(*load_credentials())
print(f"[1/6] GET workflow {WID}")
wf = client.get_workflow(WID)
print(f" versionId previo: {wf.get('versionId')} | active: {wf.get('active')} | nodos: {len(wf.get('nodes') or [])}")
existing = {n["name"] for n in wf.get("nodes") or []}
for needed in [SRC, NEXT, DECIDE, "Datos de Lead"]:
if needed not in existing:
print(f"\nERROR: falta nodo requerido {needed!r}. Abortando.")
sys.exit(2)
if NAME_LOOKUP in existing:
print(f"\nERROR: nodo {NAME_LOOKUP!r} ya existe. Abortando para no duplicar.")
sys.exit(2)
print(f"\n[2/6] Backup fresco")
_, bpath = client.backup_workflow(WID, label="pre_baserow_opp_idempotency")
print(f" backup -> {bpath}")
print(f"\n[3/6] Inyectar nodo lookup Baserow")
client.add_node(wf, build_lookup_node())
print(f" + {NAME_LOOKUP}")
print(f"\n[4/6] Reescribir codigo de '{DECIDE}'")
decide = client.find_node(wf, DECIDE)
decide["parameters"]["jsCode"] = NEW_DECIDE_CODE
print(" jsCode actualizado (chequeo Baserow -> fallback contacto -> CREATE)")
print(f"\n[5/6] Reconectar grafo")
conns = wf["connections"]
# SRC -> LOOKUP (antes SRC -> NEXT)
conns[SRC] = {"main": [[{"node": NAME_LOOKUP, "type": "main", "index": 0}]]}
# LOOKUP -> NEXT
conns[NAME_LOOKUP] = {"main": [[{"node": NEXT, "type": "main", "index": 0}]]}
# NEXT -> DECIDE se mantiene (no se toca)
print(f" {SRC} -> {NAME_LOOKUP} -> {NEXT} -> {DECIDE}")
print(f"\n[6/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
result = client.put_workflow(WID, wf, dry_run=not args.apply)
if not args.apply:
print(f" dry-run dump -> {result}")
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
return
new_wf = client.verify_post(WID, expected_node_names=[NAME_LOOKUP], prev_version_id=wf.get("versionId"))
print(f" versionId nuevo: {new_wf.get('versionId')}")
if not new_wf.get("active"):
print(" reactivando workflow...")
client.activate(WID)
new_wf = client.get_workflow(WID)
print(f" active final: {new_wf.get('active')} | nodos: {len(new_wf.get('nodes') or [])}")
print("\nOK. Workflow endurecido. Corre el test E2E para validar UPDATE vs CREATE.")
if __name__ == "__main__":
main()
+183
View File
@@ -0,0 +1,183 @@
# -*- coding: utf-8 -*-
"""Cablea el UPSERT en tiempo real del mapeo Baserow (tabla 754) tras crear/
actualizar la opp en Marca, en el workflow Cfgwp0bOtDW8zuKW.
Contexto: el lookup Baserow + 'Decidir Match' ya evitan duplicados cuando el
mapeo id_opp_sucursal->id_opp_marca existe. PERO las salidas de
'Crear Oportunidad - MARCA' y 'Actualizar Oportunidad - MARCA (v2)' estaban
VACIAS -> el mapeo solo se refrescaba con el backfill agendado (cada 30 min).
En esa ventana, una re-ejecucion del mismo opp podia volver a no encontrar el
mapeo y, si el contacto era ambiguo, crear un duplicado.
Fix (aditivo, 2 nodos, create-only condicional -> minimo riesgo):
- 'Preparar Upsert Mapeo' [code]: decide si hay que escribir el mapeo. Solo
escribe cuando NO existe aun (action CREATE, o UPDATE resuelto por contacto;
NUNCA cuando 'Decidir Match' ya lo resolvio via Baserow -> la fila ya existe,
no duplicar). Calcula id_opp_marca segun el camino. Si no hace falta -> [].
- 'Crear Mapeo - Baserow' [baserow create]: inserta la fila {id_opp_sucursal,
id_opp_marca, location_id_sucursal, updated_at}.
- Ambos con onError=continueRegularOutput: si Baserow falla, NO rompe la
replicacion (la opp ya quedo creada/actualizada aguas arriba; el backfill
completara el mapeo despues).
Idempotente: el backfill verifica por id_opp_sucursal antes de crear, asi que
una fila creada por este upsert no se duplica luego.
Dry-run por defecto (dump a n8n/dryrun_*.json); --apply para PUT real.
"""
import argparse
import os
import sys
import uuid
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.n8n_workflow_lib import load_credentials, N8NClient
WID = "Cfgwp0bOtDW8zuKW"
BASEROW_DB = 63
MAPPING_TABLE = 754
F_ID_OPP_SUCURSAL = 7280
F_ID_OPP_MARCA = 7283
F_LOCATION_SUCURSAL = 7284
F_UPDATED_AT = 7285
BASEROW_CRED = {"baserowApi": {"id": "LZztQ3WMpzXjSTIH", "name": "Baserow account"}}
NAME_PREP = "Preparar Upsert Mapeo"
NAME_CREATE = "Crear Mapeo - Baserow"
CREATE_SRC = "Crear Oportunidad - MARCA"
UPDATE_SRC = "Actualizar Oportunidad - MARCA (v2)"
DECIDE = "Decidir Match (Create vs Update)"
PREP_CODE = """// Prepara el UPSERT del mapeo Baserow (tabla 754) tras CREATE/UPDATE de la opp en Marca.
// Solo escribe cuando el mapeo NO existe aun:
// - action CREATE -> la fila no existe (el lookup Baserow fallo) -> crear.
// - action UPDATE por contacto -> Baserow no la tenia -> crear (idempotencia futura).
// - action UPDATE por Baserow -> la fila YA existe -> NO escribir (no duplicar).
// Asi una re-ejecucion del mismo opp encuentra el mapeo y hace UPDATE, no CREATE duplicado.
const decision = $('Decidir Match (Create vs Update)').first().json;
const action = decision.action;
const matchReason = decision.matchReason || '';
const sucursalOppId = decision.sucursalOppId;
const matchedByBaserow = matchReason.indexOf('Baserow') !== -1;
const needWrite = (action === 'CREATE') || (action === 'UPDATE' && !matchedByBaserow);
if (!needWrite) return [];
// id de la opp en Marca segun el camino
let marcaOppId = decision.opportunityId;
if (action === 'CREATE') {
try {
const co = $('Crear Oportunidad - MARCA').first().json;
marcaOppId = (co.opportunity && co.opportunity.id) || co.id || marcaOppId;
} catch (e) {}
}
if (!marcaOppId || !sucursalOppId) return [];
let locationSucursal = '';
try { locationSucursal = $('DATOS API - SUCURSAL').first().json['Location ID'] || ''; } catch (e) {}
return [{ json: { sucursalOppId, marcaOppId, locationSucursal } }];
"""
def build_prep_node():
return {
"parameters": {"jsCode": PREP_CODE},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [6040, 960],
"id": str(uuid.uuid4()),
"name": NAME_PREP,
"onError": "continueRegularOutput",
"notes": ("Decide si escribir el mapeo Baserow tras CREATE/UPDATE. Solo cuando la "
"fila no existe (CREATE o UPDATE-por-contacto). onError continue para no "
"romper la replicacion."),
}
def build_create_node():
return {
"parameters": {
"operation": "create",
"databaseId": BASEROW_DB,
"tableId": MAPPING_TABLE,
"fieldsUi": {
"fieldValues": [
{"fieldId": F_ID_OPP_SUCURSAL, "fieldValue": "={{ $json.sucursalOppId }}"},
{"fieldId": F_ID_OPP_MARCA, "fieldValue": "={{ $json.marcaOppId }}"},
{"fieldId": F_LOCATION_SUCURSAL, "fieldValue": "={{ $json.locationSucursal }}"},
{"fieldId": F_UPDATED_AT, "fieldValue": "={{ $now.toISO() }}"},
]
},
},
"type": "n8n-nodes-base.baserow",
"typeVersion": 1,
"position": [6280, 960],
"id": str(uuid.uuid4()),
"name": NAME_CREATE,
"credentials": BASEROW_CRED,
"onError": "continueRegularOutput",
"notes": ("Inserta el mapeo id_opp_sucursal->id_opp_marca en la tabla 754 en tiempo "
"real. Idempotente con el backfill (verifica por id_opp_sucursal)."),
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="PUT real (default: dry-run).")
args = ap.parse_args()
client = N8NClient(*load_credentials())
print(f"[1/6] GET workflow {WID}")
wf = client.get_workflow(WID)
print(f" versionId previo: {wf.get('versionId')} | active: {wf.get('active')} | nodos: {len(wf.get('nodes') or [])}")
existing = {n["name"] for n in wf.get("nodes") or []}
for needed in [CREATE_SRC, UPDATE_SRC, DECIDE]:
if needed not in existing:
print(f"\nERROR: falta nodo requerido {needed!r}. Abortando.")
sys.exit(2)
for new in [NAME_PREP, NAME_CREATE]:
if new in existing:
print(f"\nERROR: nodo {new!r} ya existe. Abortando para no duplicar.")
sys.exit(2)
print("\n[2/6] Backup fresco")
_, bpath = client.backup_workflow(WID, label="pre_baserow_opp_upsert")
print(f" backup -> {bpath}")
print("\n[3/6] Inyectar nodos")
client.add_node(wf, build_prep_node())
client.add_node(wf, build_create_node())
print(f" + {NAME_PREP}\n + {NAME_CREATE}")
print("\n[4/6] Reconectar grafo (aditivo; las salidas estaban vacias)")
conns = wf["connections"]
conns[CREATE_SRC] = {"main": [[{"node": NAME_PREP, "type": "main", "index": 0}]]}
conns[UPDATE_SRC] = {"main": [[{"node": NAME_PREP, "type": "main", "index": 0}]]}
conns[NAME_PREP] = {"main": [[{"node": NAME_CREATE, "type": "main", "index": 0}]]}
print(f" {CREATE_SRC} -> {NAME_PREP}")
print(f" {UPDATE_SRC} -> {NAME_PREP}")
print(f" {NAME_PREP} -> {NAME_CREATE}")
print(f"\n[5/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
result = client.put_workflow(WID, wf, dry_run=not args.apply)
if not args.apply:
print(f" dry-run dump -> {result}")
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
return
print("\n[6/6] Verificar")
new_wf = client.verify_post(WID, expected_node_names=[NAME_PREP, NAME_CREATE],
prev_version_id=wf.get("versionId"))
print(f" versionId nuevo: {new_wf.get('versionId')}")
if not new_wf.get("active"):
print(" reactivando workflow...")
client.activate(WID)
new_wf = client.get_workflow(WID)
print(f" active final: {new_wf.get('active')} | nodos: {len(new_wf.get('nodes') or [])}")
print("\nOK. Upsert Baserow en tiempo real cableado (degradacion elegante via onError).")
if __name__ == "__main__":
main()
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Complementa el workflow n8n [2004] (ddUEORBEtZLzsQF2) para que, al crear un
contacto en sucursal, además de rellenar contact.sucursal / contact.tienda,
escriba "Canal de Origen" = SUCURSAL y deje el tag de origen correcto, PERO solo
cuando el contacto fue creado por un usuario (createdBy.source {WEB_USER,
MOBILE_USER}). Los creados por integración (INTEGRATION) no se tocan.
Replica en TIEMPO REAL el criterio del batch scripts/fix_branch_user_origin.py.
Cambios (solo AÑADE, preserva el flujo actual):
1. Extiende el Code node "Buscar contact.sucursal y contact.tienda" para
resolver también el field "Canal de Origen" (por fieldKey con fallback a
nombre) y exponer createdBySource / esUsuario (leído del GET del contacto).
2. Tras el PUT actual de sucursal/tienda añade:
IF "Creado por usuario" -> [true] PUT Canal de Origen = SUCURSAL
-> Tag+ sucursal -> Tag- formulario -> Tag- facebook-ads
[false] (fin: no se toca).
Uso:
python n8n/_add_canal_origen_branch.py # dry-run (dumpea payload)
python n8n/_add_canal_origen_branch.py --apply # aplica + reactiva
"""
import argparse
import os
import sys
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
sys.path.insert(0, ROOT_DIR)
from scripts.n8n_workflow_lib import ( # noqa: E402
AlreadyExistsError,
N8NClient,
load_credentials,
)
WID = "ddUEORBEtZLzsQF2"
# Nodos existentes (referencias).
CODE_NODE = 'Buscar "contact.sucursal" y "contact.tienda"'
GET_CONTACT_NODE = "Obtener Contacto Cuenta Origen - SUCURSAL"
PUT_SUCURSAL_NODE = "Actualizar Contacto Cuenta Objetivo - SUCURSAL"
VERIFICADOR_NODE = "Buscar Sucursal en Verificador de Sucursales"
# Nodos nuevos.
IF_NODE = "Creado por usuario (Canal de Origen)"
PUT_CANAL_NODE = "PUT Canal de Origen = SUCURSAL"
TAG_ADD_NODE = "Tag+ sucursal"
TAG_RM_FORM_NODE = "Tag- formulario"
TAG_RM_FB_NODE = "Tag- facebook-ads"
# Expresiones reutilizables (referencian nodos upstream por nombre).
CONTACT_ID_EXPR = "{{ $('Datos de Lead').item.json.Cliente['Contact ID'] }}"
TOKEN_EXPR = "{{ $('" + VERIFICADOR_NODE + "').item.json['SC TOKEN BUCEFALO'] }}"
CODE_REF = "$('" + CODE_NODE + "').item.json"
HEADERS = [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization", "value": "=Bearer " + TOKEN_EXPR},
]
# Code node extendido: preserva sucursal/tienda EXACTO y añade canal + esUsuario.
NEW_JSCODE = r"""const customFields = $input.first().json.customFields;
function findField(key, names) {
let f = customFields.find(x => x.fieldKey === key);
if (!f) {
const wanted = names.map(n => n.toLowerCase().trim());
f = customFields.find(x => wanted.includes((x.name || "").toLowerCase().trim()));
}
return f || null;
}
const sucursalField = findField("contact.sucursal", ["Sucursal"]);
const tiendaField = findField("contact.tienda", ["TIENDA", "Tienda"]);
const canalField = findField("contact.fuente_de_posible_cliente", ["CANAL DE ORIGEN", "Canal de Origen"]);
// createdBy.source SOLO viene del GET individual del contacto.
const contactResp = $('Obtener Contacto Cuenta Origen - SUCURSAL').item.json;
const createdBySource =
(contactResp && contactResp.contact && contactResp.contact.createdBy && contactResp.contact.createdBy.source) ||
(contactResp && contactResp.createdBy && contactResp.createdBy.source) ||
null;
const esUsuario = createdBySource === "WEB_USER" || createdBySource === "MOBILE_USER";
return [
{
json: {
sucursal: {
id: sucursalField?.id ?? null,
name: sucursalField?.name ?? null,
fieldKey: sucursalField?.fieldKey ?? null,
picklistOptions: sucursalField?.picklistOptions ?? [],
},
tienda: {
id: tiendaField?.id ?? null,
name: tiendaField?.name ?? null,
fieldKey: tiendaField?.fieldKey ?? null,
picklistOptions: tiendaField?.picklistOptions ?? [],
},
canal: {
id: canalField?.id ?? null,
name: canalField?.name ?? null,
fieldKey: canalField?.fieldKey ?? null,
picklistOptions: canalField?.picklistOptions ?? [],
},
createdBySource: createdBySource,
esUsuario: esUsuario,
},
},
];"""
def http_node(name, position, *, method, body_obj_expr, on_error=False):
"""Construye un nodo httpRequest (typeVersion 4.2) con auth Bearer en header."""
params = {
"method": method,
"url": "=https://services.leadconnectorhq.com/contacts/" + CONTACT_ID_EXPR + ("/tags" if method in ("POST", "DELETE") else ""),
"sendHeaders": True,
"headerParameters": {"parameters": [dict(h) for h in HEADERS]},
"sendBody": True,
"specifyBody": "json",
"jsonBody": body_obj_expr,
"options": {"redirect": {"redirect": {}}},
}
node = {
"parameters": params,
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": position,
"name": name,
}
if on_error:
# Un DELETE de un tag inexistente puede no aplicar; no debe romper el flujo.
node["onError"] = "continueRegularOutput"
return node
def build_nodes():
# PUT Canal de Origen = SUCURSAL (solo este CF; NO toca Fuente de Prospecto).
canal_body = (
"={\n"
' "customFields": [\n'
" {\n"
' "id": "{{ ' + CODE_REF + '.canal.id }}",\n'
' "key": "{{ ' + CODE_REF + '.canal.fieldKey }}",\n'
' "field_value": "SUCURSAL"\n'
" }\n"
" ]\n"
"}"
)
put_canal = {
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/" + CONTACT_ID_EXPR,
"sendHeaders": True,
"headerParameters": {"parameters": [dict(h) for h in HEADERS]},
"sendBody": True,
"specifyBody": "json",
"jsonBody": canal_body,
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [940, -120],
"name": PUT_CANAL_NODE,
}
if_node = {
"parameters": {
"conditions": {
"options": {"caseSensitive": True, "leftValue": "", "typeValidation": "loose", "version": 3},
"conditions": [
{
"id": "canal-origen-esusuario",
"leftValue": "={{ " + CODE_REF + ".esUsuario }}",
"rightValue": "",
"operator": {"type": "boolean", "operation": "true", "singleValue": True},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [720, -120],
"name": IF_NODE,
}
tag_add = http_node(TAG_ADD_NODE, [1160, -120], method="POST",
body_obj_expr='={\n "tags": ["sucursal"]\n}')
tag_rm_form = http_node(TAG_RM_FORM_NODE, [1380, -120], method="DELETE",
body_obj_expr='={\n "tags": ["formulario"]\n}', on_error=True)
tag_rm_fb = http_node(TAG_RM_FB_NODE, [1600, -120], method="DELETE",
body_obj_expr='={\n "tags": ["facebook-ads"]\n}', on_error=True)
return if_node, put_canal, tag_add, tag_rm_form, tag_rm_fb
def main():
parser = argparse.ArgumentParser(description="Añade la rama Canal de Origen al workflow n8n [2004].")
parser.add_argument("--apply", action="store_true", help="Aplica el PUT real (sin esto: dry-run).")
args = parser.parse_args()
client = N8NClient(*load_credentials())
wf, backup_path = client.backup_workflow(WID, label="canal_origen")
prev_version = wf.get("versionId")
print(f"Workflow: {wf.get('name')}")
print(f" active={wf.get('active')} nodes={len(wf.get('nodes') or [])} versionId={prev_version}")
print(f" backup -> {backup_path}")
# Idempotencia: si ya están los nodos nuevos, no re-aplicar.
for nm in (IF_NODE, PUT_CANAL_NODE, TAG_ADD_NODE):
if client.find_node(wf, nm) is not None:
raise SystemExit(f"El nodo {nm!r} ya existe; el complemento ya fue aplicado. Nada que hacer.")
# 1. Extender el Code node.
code_node = client.find_node(wf, CODE_NODE)
if code_node is None:
raise SystemExit(f"No se encontró el Code node {CODE_NODE!r}.")
code_node["parameters"]["jsCode"] = NEW_JSCODE
print(f" Code node {CODE_NODE!r}: jsCode extendido (canal + esUsuario).")
# 2. Añadir nodos nuevos.
if_node, put_canal, tag_add, tag_rm_form, tag_rm_fb = build_nodes()
for n in (if_node, put_canal, tag_add, tag_rm_form, tag_rm_fb):
client.assert_idempotent(wf, n["name"])
client.add_node(wf, n)
print(" Nodos añadidos: IF + PUT canal + 3 de tags.")
# 3. Conexiones: PUT sucursal -> IF -> [true] PUT canal -> tag+ -> tag- form -> tag- fb.
client.set_connection(wf, PUT_SUCURSAL_NODE, IF_NODE) # main:0
client.set_connection(wf, IF_NODE, PUT_CANAL_NODE, output_index=0) # rama true
# rama false (index 1): sin destino (fin).
client.set_connection(wf, PUT_CANAL_NODE, TAG_ADD_NODE)
client.set_connection(wf, TAG_ADD_NODE, TAG_RM_FORM_NODE)
client.set_connection(wf, TAG_RM_FORM_NODE, TAG_RM_FB_NODE)
print(" Conexiones cableadas.")
expected = [IF_NODE, PUT_CANAL_NODE, TAG_ADD_NODE, TAG_RM_FORM_NODE, TAG_RM_FB_NODE]
if not args.apply:
res = client.put_workflow(WID, wf, dry_run=True)
print(f"\nDRY-RUN. Payload -> {res['path']} ({res['node_count']} nodos).")
print("Revisa el JSON y vuelve a correr con --apply para aplicar.")
return
was_active = bool(wf.get("active"))
if was_active:
try:
client.deactivate(WID)
print(" Workflow desactivado para PUT estructural.")
except Exception as exc:
print(f" WARN al desactivar: {exc}")
client.put_workflow(WID, wf, dry_run=False)
print(" PUT aplicado.")
if was_active:
client.activate(WID)
print(" Workflow reactivado.")
client.verify_post(WID, expected_node_names=expected, prev_version_id=prev_version)
print("\nOK: complemento aplicado y verificado. Backup en:", backup_path)
if __name__ == "__main__":
main()
+203
View File
@@ -0,0 +1,203 @@
# -*- coding: utf-8 -*-
"""Fallback de autoenlace para el workflow Sucursal->Marca (x4DqZ5FtSc43tdzB).
Inserta entre 'Mapear Custom Fields Cuenta Origen - SUCURSAL' y
'DATOS CUENTA OBJETIVO' un IF + PUT condicional:
- Si el CF 'contact.id_contacto_sucursal' ya == contact.id -> bypass directo.
- Si NO -> PUT autoenlace en la sucursal antes de continuar.
Defensivo contra automatizaciones nativas de GHL que crean contactos sin
disparar nuestro fill. onError=continueRegularOutput para no romper el sync
si el PUT falla.
Modo dry-run por defecto (dumpea n8n/dryrun_*.json sin tocar la API).
Pasar --apply para PUT real + reactivar.
"""
import argparse
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.n8n_workflow_lib import load_credentials, N8NClient
import uuid
WID = "x4DqZ5FtSc43tdzB"
NAME_IF = "ya esta el CF id_contacto_sucursal"
NAME_PUT = "Autoenlace CF id_contacto_sucursal - SUCURSAL"
SRC = "Mapear Custom Fields Cuenta Origen - SUCURSAL"
DST = "DATOS CUENTA OBJETIVO"
def build_if_node():
return {
"parameters": {
"conditions": {
"options": {
"caseSensitive": True,
"leftValue": "",
"typeValidation": "strict",
"version": 2,
},
"conditions": [
{
"id": str(uuid.uuid4()),
"leftValue": "={{ $json['contact.id_contacto_sucursal'] }}",
"rightValue": "={{ $json.id }}",
"operator": {
"type": "string",
"operation": "equals",
"name": "filter.operator.equals",
},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [1760, -288],
"id": str(uuid.uuid4()),
"name": NAME_IF,
"notes": (
"Fallback defensivo. Si el CF 'contact.id_contacto_sucursal' ya == contact.id "
"(estado normal poblado por fill_contact_id_sucursal.py), pasa directo. "
"Si esta vacio o distinto (creacion por automatizacion nativa de GHL que se "
"salto nuestro fill), va a la rama PUT para autoenlazar antes de tocar Marca."
),
}
def build_put_node():
json_body = (
"={{ JSON.stringify({ customFields: [ { "
"id: $('Conseguir Custom Cuenta Origen- SUCURSAL').first().json.customFields"
".find(f => f.fieldKey === 'contact.id_contacto_sucursal')?.id, "
"key: 'contact.id_contacto_sucursal', "
"field_value: $('Mapear Custom Fields Cuenta Origen - SUCURSAL').item.json.id"
" } ] }) }}"
)
return {
"parameters": {
"method": "PUT",
"url": (
"=https://services.leadconnectorhq.com/contacts/"
"{{ $('Mapear Custom Fields Cuenta Origen - SUCURSAL').item.json.id }}"
),
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{
"name": "Authorization",
"value": (
"=Bearer "
"{{ $('Obtener Info de cuenta origen - SUCURSAL').item.json.API_token }}"
),
},
{"name": "Content-Type", "value": "application/json"},
]
},
"sendBody": True,
"specifyBody": "json",
"jsonBody": json_body,
"options": {},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1936, -192],
"id": str(uuid.uuid4()),
"name": NAME_PUT,
"onError": "continueRegularOutput",
"notes": (
"Autoenlace de seguridad: setea CF 'contact.id_contacto_sucursal' = "
"contact.id propio en la sucursal. Solo se ejecuta si el IF previo "
"detecto que el CF estaba vacio o distinto. onError=continue para no "
"romper el sync si el PUT falla."
),
}
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Aplicar PUT real (default: dry-run a n8n/dryrun_*.json)")
return ap.parse_args()
def main():
args = parse_args()
api_key, base_url = load_credentials()
client = N8NClient(api_key, base_url)
print(f"[1/6] GET workflow {WID}")
wf = client.get_workflow(WID)
print(f" versionId previo: {wf.get('versionId')}")
print(f" active : {wf.get('active')}")
print(f" total nodos : {len(wf.get('nodes') or [])}")
existing = {n["name"] for n in wf.get("nodes") or []}
if NAME_IF in existing or NAME_PUT in existing:
print(f"\nERROR: los nodos ya existen ({NAME_IF!r} o {NAME_PUT!r}). Abortando.")
sys.exit(2)
if SRC not in existing:
print(f"\nERROR: no encontre el nodo source {SRC!r}. Abortando.")
sys.exit(2)
if DST not in existing:
print(f"\nERROR: no encontre el nodo destino {DST!r}. Abortando.")
sys.exit(2)
print(f"\n[2/6] Backup fresco")
_, bpath = client.backup_workflow(WID, label="pre_fallback_autoenlace")
print(f" backup -> {bpath}")
print(f"\n[3/6] Inyectar nodos IF + PUT")
if_node = build_if_node()
put_node = build_put_node()
client.add_node(wf, if_node)
client.add_node(wf, put_node)
print(f" IF : {NAME_IF}")
print(f" PUT: {NAME_PUT}")
print(f"\n[4/6] Reconectar grafo")
# Reemplaza SRC -> DST por SRC -> IF
client.insert_between(wf, SRC, NAME_IF, DST)
# IF.output(0)=true (CF ya ok) -> DST; IF.output(1)=false (CF vacio/distinto) -> PUT -> DST
client.branch_if(wf, NAME_IF, true_target=DST, false_target=NAME_PUT)
# PUT -> DST (convergencia)
conns = wf["connections"]
conns[NAME_PUT] = {"main": [[{"node": DST, "type": "main", "index": 0}]]}
print(f" {SRC} -> {NAME_IF}")
print(f" {NAME_IF} [true] -> {DST}")
print(f" {NAME_IF} [false] -> {NAME_PUT}")
print(f" {NAME_PUT} -> {DST}")
print(f"\n[5/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
result = client.put_workflow(WID, wf, dry_run=not args.apply)
if not args.apply:
print(f" dry-run dump -> {result}")
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
return
print(f"\n[6/6] Verificar y reactivar")
new_wf = client.verify_post(
WID,
expected_node_names=[NAME_IF, NAME_PUT],
prev_version_id=wf.get("versionId"),
)
print(f" versionId nuevo: {new_wf.get('versionId')}")
if not new_wf.get("active"):
print(" workflow quedo inactivo tras PUT; reactivando...")
client.activate(WID)
new_wf = client.get_workflow(WID)
print(f" active final : {new_wf.get('active')}")
print(f" total nodos : {len(new_wf.get('nodes') or [])}")
print("\nOK.")
if __name__ == "__main__":
main()
+265
View File
@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
"""Fallback post-UPDATE para el workflow Marca->Sucursal V2 (4UMRwxJdHFfOGHBp).
Cierra el gap del path UPDATE-via-cascada: cuando el contacto Marca llega sin
CF id_contacto_sucursal poblado, va por cascada (mail/telefono/nombre), encuentra
contacto sucursal existente, lo actualiza... y el CF Marca queda vacio para
siempre (la siguiente ejecucion volvera por cascada).
Inserta tras 'Actualizar Contacto Cuenta Objetivo - SUCURSAL':
IF '¿cfValue de Marca estaba vacio?' (eval: cfValue extraido al inicio)
true -> PUT 1 (CF en MARCA = id sucursal encontrado)
-> PUT 2 (CF en SUCURSAL = autoref)
false -> (fin, ya estaba enlazado)
Defensivo: onError=continueRegularOutput en ambos PUTs.
Modo dry-run por defecto; --apply para PUT real + reactivar.
"""
import argparse
import sys
import os
import uuid
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.n8n_workflow_lib import load_credentials, N8NClient
WID = "4UMRwxJdHFfOGHBp"
BRAND_CF_ID = "E6lI9ykWhqpj7Pmi7Qd3" # CF id_contacto_sucursal en Marca (hardcoded)
NAME_IF = "cfValue Marca estaba vacio"
NAME_PUT_MARCA = "Autoenlace post-UPDATE: CF en MARCA"
NAME_PUT_SUC = "Autoenlace post-UPDATE: CF en SUCURSAL (autoref)"
# Nodo terminal donde nos enganchamos
SRC_TERMINAL = "Actualizar Contacto Cuenta Objetivo - SUCURSAL"
def build_if_node():
return {
"parameters": {
"conditions": {
"options": {
"caseSensitive": True,
"leftValue": "",
"typeValidation": "loose",
"version": 3,
},
"conditions": [
{
"id": str(uuid.uuid4()),
"leftValue": "={{ $('Extraer id_contacto_sucursal de Marca').item.json.cfValue }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "empty",
"singleValue": True,
},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [3984, 480],
"id": str(uuid.uuid4()),
"name": NAME_IF,
"notes": (
"Fallback post-UPDATE: si el cfValue extraido al inicio estaba vacio, "
"entonces la cascada nos trajo aqui. Autoenlazamos ambos lados para "
"que la proxima ejecucion vaya por la ruta deterministica."
),
}
def build_put_marca():
json_body = (
'={\n'
' "customFields": [\n'
' {\n'
f' "id": "{BRAND_CF_ID}",\n'
' "key": "contact.id_contacto_sucursal",\n'
' "field_value": "{{ $(\'Obtener datos completos de Contacto objetivo - SUCURSAL\').item.json.id }}"\n'
' }\n'
' ]\n'
'}'
)
return {
"parameters": {
"method": "PUT",
"url": (
"=https://services.leadconnectorhq.com/contacts/"
"{{ $('Webhook').item.json.body.contact_id }}"
),
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{
"name": "Authorization",
"value": (
"=Bearer "
"{{ $('Datos API Cuenta Origen').item.json['Token/API'] }}"
),
},
{"name": "Content-Type", "value": "application/json"},
]
},
"sendBody": True,
"specifyBody": "json",
"jsonBody": json_body,
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [4176, 384],
"id": str(uuid.uuid4()),
"name": NAME_PUT_MARCA,
"onError": "continueRegularOutput",
"notes": "Setea CF id_contacto_sucursal en MARCA = id del contacto sucursal encontrado.",
}
def build_put_sucursal():
json_body = (
'={{ JSON.stringify({ customFields: [ { '
"id: $('Conseguir Custom Cuenta objetivo - SUCURSAL').first().json.customFields"
".find(f => f.fieldKey === 'contact.id_contacto_sucursal')?.id, "
"key: 'contact.id_contacto_sucursal', "
"field_value: $('Obtener datos completos de Contacto objetivo - SUCURSAL').item.json.id"
' } ] }) }}'
)
return {
"parameters": {
"method": "PUT",
"url": (
"=https://services.leadconnectorhq.com/contacts/"
"{{ $('Obtener datos completos de Contacto objetivo - SUCURSAL').item.json.id }}"
),
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{
"name": "Authorization",
"value": (
"=Bearer "
"{{ $('Datos API Cuenta objetivo - SUCURSAL').item.json['Token/API'] }}"
),
},
{"name": "Content-Type", "value": "application/json"},
]
},
"sendBody": True,
"specifyBody": "json",
"jsonBody": json_body,
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [4368, 384],
"id": str(uuid.uuid4()),
"name": NAME_PUT_SUC,
"onError": "continueRegularOutput",
"notes": "Autoref: setea CF en sucursal = su propio contact.id.",
}
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Aplicar PUT real (default: dry-run a n8n/dryrun_*.json)")
return ap.parse_args()
def main():
args = parse_args()
api_key, base_url = load_credentials()
client = N8NClient(api_key, base_url)
print(f"[1/6] GET workflow {WID}")
wf = client.get_workflow(WID)
print(f" versionId previo: {wf.get('versionId')}")
print(f" active : {wf.get('active')}")
print(f" total nodos : {len(wf.get('nodes') or [])}")
existing = {n["name"] for n in wf.get("nodes") or []}
for needed_existing in [SRC_TERMINAL, "Extraer id_contacto_sucursal de Marca",
"Webhook", "Datos API Cuenta Origen",
"Datos API Cuenta objetivo - SUCURSAL",
"Conseguir Custom Cuenta objetivo - SUCURSAL",
"Obtener datos completos de Contacto objetivo - SUCURSAL"]:
if needed_existing not in existing:
print(f"\nERROR: falta nodo requerido {needed_existing!r}. Abortando.")
sys.exit(2)
for new_name in [NAME_IF, NAME_PUT_MARCA, NAME_PUT_SUC]:
if new_name in existing:
print(f"\nERROR: nodo {new_name!r} ya existe. Abortando para no duplicar.")
sys.exit(2)
print(f"\n[2/6] Backup fresco")
_, bpath = client.backup_workflow(WID, label="pre_fallback_post_update")
print(f" backup -> {bpath}")
print(f"\n[3/6] Inyectar IF + 2 PUTs")
if_node = build_if_node()
put_marca = build_put_marca()
put_suc = build_put_sucursal()
client.add_node(wf, if_node)
client.add_node(wf, put_marca)
client.add_node(wf, put_suc)
print(f" IF : {NAME_IF}")
print(f" PUT Marca: {NAME_PUT_MARCA}")
print(f" PUT Suc : {NAME_PUT_SUC}")
print(f"\n[4/6] Reconectar grafo")
conns = wf["connections"]
# SRC_TERMINAL -> IF
conns[SRC_TERMINAL] = {"main": [[{"node": NAME_IF, "type": "main", "index": 0}]]}
# IF [true]=output0 -> PUT_MARCA; IF [false]=output1 -> nada (branch vacio, no None)
conns[NAME_IF] = {
"main": [
[{"node": NAME_PUT_MARCA, "type": "main", "index": 0}], # true
[], # false: sin destino
]
}
# PUT_MARCA -> PUT_SUC
conns[NAME_PUT_MARCA] = {"main": [[{"node": NAME_PUT_SUC, "type": "main", "index": 0}]]}
# PUT_SUC -> (terminal)
print(f" {SRC_TERMINAL} -> {NAME_IF}")
print(f" {NAME_IF} [true] -> {NAME_PUT_MARCA}")
print(f" {NAME_IF} [false] -> (fin)")
print(f" {NAME_PUT_MARCA} -> {NAME_PUT_SUC}")
print(f" {NAME_PUT_SUC} -> (fin)")
print(f"\n[5/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
result = client.put_workflow(WID, wf, dry_run=not args.apply)
if not args.apply:
print(f" dry-run dump -> {result}")
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
return
print(f"\n[6/6] Verificar y reactivar")
new_wf = client.verify_post(
WID,
expected_node_names=[NAME_IF, NAME_PUT_MARCA, NAME_PUT_SUC],
prev_version_id=wf.get("versionId"),
)
print(f" versionId nuevo: {new_wf.get('versionId')}")
if not new_wf.get("active"):
print(" workflow inactivo tras PUT; reactivando...")
client.activate(WID)
new_wf = client.get_workflow(WID)
print(f" active final : {new_wf.get('active')}")
print(f" total nodos : {len(new_wf.get('nodes') or [])}")
print("\nOK.")
if __name__ == "__main__":
main()
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Fase 1 — SUCURSAL → MARCA: match primario por id_contacto_sucursal.
Insertar entre `Datos API Cuenta objetivo - MARCA` y `Buscar Contacto Objetivo
- MARCA (phone)`:
1) HTTP search por CF
2) IF que decide CF match único
- true Capturar ID Match (reutilizado)
- false Buscar Contacto Objetivo - MARCA (phone) (cascada existente)
Modificar los code nodes de CREATE y UPDATE para que inyecten el CF
`contact.id_contacto_sucursal` con el id del contacto origen, garantizando
enlace incluso si el match cayó a la cascada.
Uso:
python n8n/_apply_phase1.py # dry-run
python n8n/_apply_phase1.py --apply
"""
import argparse
import copy
import json
import os
import re
import sys
import uuid
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from n8n_workflow_lib import load_credentials, N8NClient # noqa: E402
WID = "x4DqZ5FtSc43tdzB"
CF_ID_BRAND = "E6lI9ykWhqpj7Pmi7Qd3"
NEW_HTTP_NAME = "Buscar Contacto Objetivo - MARCA (id_contacto_sucursal)"
NEW_IF_NAME = "¿id_contacto_sucursal match único?"
def http_node_search_by_cf():
return {
"parameters": {
"method": "POST",
"url": "https://services.leadconnectorhq.com/contacts/search",
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization",
"value": "=Bearer {{ $('Datos API Cuenta objetivo - MARCA').item.json['Token/API'] }}"},
]
},
"sendBody": True,
"specifyBody": "json",
"jsonBody": (
"={\n"
" \"locationId\": \"{{ $('Datos API Cuenta objetivo - MARCA').item.json['Location ID'] }}\",\n"
" \"pageLimit\": 5,\n"
" \"filters\": [\n"
" {\n"
" \"group\": \"AND\",\n"
" \"filters\": [\n"
" {\n"
" \"field\": \"customFields." + CF_ID_BRAND + "\",\n"
" \"operator\": \"eq\",\n"
" \"value\": \"{{ $('Crear Contacto').item.json.body.contact_id }}\"\n"
" }\n"
" ]\n"
" }\n"
" ]\n"
"}"
),
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [2832, -496],
"id": str(uuid.uuid4()),
"name": NEW_HTTP_NAME,
"onError": "continueRegularOutput",
"alwaysOutputData": True,
"notes": (
"FASE 1 — Match primario por contact.id_contacto_sucursal. "
"Si total==1, el siguiente IF deriva a Capturar ID Match. "
"Si no, sigue a la cascada phone→email→nombre."
),
}
def if_node_cf_match():
return {
"parameters": {
"conditions": {
"options": {
"caseSensitive": True,
"leftValue": "",
"typeValidation": "strict",
"version": 3,
},
"conditions": [
{
"id": str(uuid.uuid4()),
"leftValue": "={{ $json.total }}",
"rightValue": 1,
"operator": {"type": "number", "operation": "equals"},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [3040, -496],
"id": str(uuid.uuid4()),
"name": NEW_IF_NAME,
"notes": "FASE 1. true → Capturar ID Match. false → cascada phone.",
}
# ---- Patch helpers para code nodes ----
INJECT_BLOCK_UPDATE = (
"\n// === FASE 1: inyectar CF id_contacto_sucursal (defensive) ===\n"
"var __linkId = '" + CF_ID_BRAND + "';\n"
"customFields = customFields.filter(function(c){ return c.id !== __linkId; });\n"
"if (origenData && origenData.id) {\n"
" customFields.push({ id: __linkId, key: 'contact.id_contacto_sucursal', field_value: origenData.id });\n"
"}\n"
)
INJECT_BLOCK_CREATE_FK = (
"\n// === FASE 1: garantizar CF contact.id_contacto_sucursal ===\n"
"(function(){\n"
" var origenId = ($('Obtener Contacto Cuenta Origen - SUCURSAL').first().json.contact || {}).id;\n"
" if (!origenId) return;\n"
" // Encontrar el def en Marca por fieldKey.\n"
" var defLink = marcaCustomDefs.find(function(d){ return d.fieldKey === 'contact.id_contacto_sucursal'; });\n"
" if (!defLink) return;\n"
" customFields = customFields.filter(function(c){ return c.id !== defLink.id; });\n"
" customFields.push({ id: defLink.id, field_value: origenId });\n"
"})();\n"
)
def patch_update_code(wf, client):
"""Modifica `Obtener Body para Actualizar Contacto Objetivo - MARCA`."""
node = client.find_node(wf, "Obtener Body para Actualizar Contacto Objetivo - MARCA")
if not node:
raise RuntimeError("nodo update no encontrado")
code = node["parameters"].get("jsCode") or ""
if "FASE 1: inyectar CF id_contacto_sucursal" in code:
print(" [skip] code update ya tiene el bloque FASE 1")
return False
# Insertar antes de `var body = {`
marker = "var body = {"
idx = code.rfind(marker)
if idx < 0:
raise RuntimeError("marker 'var body = {' no encontrado en code update")
new_code = code[:idx] + INJECT_BLOCK_UPDATE + code[idx:]
node["parameters"]["jsCode"] = new_code
return True
def patch_create_code(wf, client):
"""Modifica `Obtener el body para crear Contacto - MARCA`."""
node = client.find_node(wf, "Obtener el body para crear Contacto - MARCA")
if not node:
raise RuntimeError("nodo create no encontrado")
code = node["parameters"].get("jsCode") or ""
if "FASE 1: garantizar CF contact.id_contacto_sucursal" in code:
print(" [skip] code create ya tiene el bloque FASE 1")
return False
marker = "var body = {"
idx = code.rfind(marker)
if idx < 0:
raise RuntimeError("marker 'var body = {' no encontrado en code create")
new_code = code[:idx] + INJECT_BLOCK_CREATE_FK + code[idx:]
node["parameters"]["jsCode"] = new_code
return True
# ---- Main ----
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true", help="Aplica al workflow. Sin esto: dry-run.")
parser.add_argument("--activate", action="store_true", help="Tras apply OK, activar el workflow.")
args = parser.parse_args()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
client = N8NClient(*load_credentials())
print(f"[fase 1] GET workflow {WID}")
wf, backup_path = client.backup_workflow(WID, label="fase1_pre")
prev_version = wf.get("versionId")
print(f" backup: {backup_path}")
print(f" versionId: {prev_version} active: {wf.get('active')}")
print(f" nodes pre: {len(wf['nodes'])}")
# Trabajar sobre copia
wfm = copy.deepcopy(wf)
# Idempotencia
try:
client.assert_idempotent(wfm, NEW_HTTP_NAME)
client.assert_idempotent(wfm, NEW_IF_NAME)
except Exception as e:
raise SystemExit(f"Aborto: {e}")
# Insertar nodos
print("[fase 1] insertando nodos nuevos...")
http_node = http_node_search_by_cf()
if_node = if_node_cf_match()
client.add_node(wfm, http_node)
client.add_node(wfm, if_node)
# Reconectar:
# antes: Datos API Cuenta objetivo - MARCA → Buscar Contacto Objetivo - MARCA (phone)
# ahora: Datos API → HTTP nuevo → IF nuevo
# IF true → Capturar ID Match
# IF false → Buscar Contacto Objetivo - MARCA (phone)
client.set_connection(wfm, "Datos API Cuenta objetivo - MARCA", NEW_HTTP_NAME)
client.set_connection(wfm, NEW_HTTP_NAME, NEW_IF_NAME)
client.branch_if(wfm, NEW_IF_NAME,
true_target="Capturar ID Match",
false_target="Buscar Contacto Objetivo - MARCA (phone)")
# Modificar code nodes
print("[fase 1] modificando code UPDATE...")
patch_update_code(wfm, client)
print("[fase 1] modificando code CREATE...")
patch_create_code(wfm, client)
print(f"[fase 1] nodes post: {len(wfm['nodes'])} (+{len(wfm['nodes'])-len(wf['nodes'])})")
# Dry-run
print("[fase 1] dry-run PUT (dump a archivo)...")
res = client.put_workflow(WID, wfm, dry_run=True)
print(f" dry-run path: {res['path']}")
if not args.apply:
print("\nDRY-RUN. Para aplicar: --apply [--activate]")
return
# Apply real
print("[fase 1] PUT real...")
client.put_workflow(WID, wfm, dry_run=False)
print(" PUT OK. Verificando...")
wf_post = client.verify_post(
WID,
expected_node_names=[NEW_HTTP_NAME, NEW_IF_NAME],
prev_version_id=prev_version,
)
print(f" versionId nuevo: {wf_post.get('versionId')}")
print(f" active post-PUT: {wf_post.get('active')}")
if args.activate or not wf_post.get("active"):
print("[fase 1] activando workflow...")
client.activate(WID)
wf_post2 = client.get_workflow(WID)
print(f" active final: {wf_post2.get('active')}")
print("\n[fase 1] aplicado y activado.")
print("Siguiente paso: python scripts/n8n_e2e_test.py --scenario all-phase1")
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Fase 1 FIX: el nodo `Buscar Contacto Objetivo - MARCA (phone)` referenciaba
`$json['Token/API']` y `$json['Location ID']` asumiendo que el input previo era
`Datos API Cuenta objetivo - MARCA`. Tras la Fase 1, el input proviene del IF
nuevo (que solo trae `contacts` y `total`). Cambiamos las refs a explícitas.
Idempotente.
"""
import argparse
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from n8n_workflow_lib import load_credentials, N8NClient # noqa: E402
WID = "x4DqZ5FtSc43tdzB"
TARGET_NODE = "Buscar Contacto Objetivo - MARCA (phone)"
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
client = N8NClient(*load_credentials())
wf, backup_path = client.backup_workflow(WID, label="fase1_fix_phone_refs")
print(f"backup: {backup_path}")
prev = wf.get("versionId")
node = client.find_node(wf, TARGET_NODE)
if not node:
raise SystemExit(f"nodo {TARGET_NODE!r} no encontrado")
p = node["parameters"]
# Cambiar Authorization header
for h in p.get("headerParameters", {}).get("parameters", []):
if h.get("name") == "Authorization":
old = h["value"]
new = old.replace(
"$json['Token/API']",
"$('Datos API Cuenta objetivo - MARCA').item.json['Token/API']"
)
if old != new:
h["value"] = new
print(f" Authorization: actualizado")
else:
print(f" Authorization: ya tenía referencia explícita (skip)")
# Cambiar jsonBody locationId
body = p.get("jsonBody") or ""
new_body = body.replace(
"$json['Location ID']",
"$('Datos API Cuenta objetivo - MARCA').item.json['Location ID']"
)
if new_body != body:
p["jsonBody"] = new_body
print(f" jsonBody locationId: actualizado")
else:
print(f" jsonBody locationId: ya tenía referencia explícita (skip)")
# Dry-run / apply
if not args.apply:
res = client.put_workflow(WID, wf, dry_run=True)
print(f"DRY-RUN. path: {res['path']}")
return
client.put_workflow(WID, wf, dry_run=False)
wf2 = client.verify_post(WID, expected_node_names=[TARGET_NODE], prev_version_id=prev)
print(f"versionId nuevo: {wf2.get('versionId')}")
print(f"active: {wf2.get('active')}")
if not wf2.get("active"):
client.activate(WID)
print("activado.")
print("OK")
if __name__ == "__main__":
main()
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Fase 2 — MARCA → SUCURSAL V2: match directo por id_contacto_sucursal.
Insertar entre `Datos API Cuenta objetivo - SUCURSAL` y
`Buscar Contacto Objetivo - SUCURSAL(mail)`:
1) Code `Extraer id_contacto_sucursal de Marca` saca CF del contacto Marca.
2) IF `¿cfValue presente?` si vacío salta directo a cascada email.
3) HTTP GET `Match directo por id_contacto_sucursal` GET /contacts/{cfValue}
con token sucursal. onError: continueRegularOutput.
4) IF `¿GET de contacto sucursal OK?` trueConseguir Custom...SUCURSAL (UPDATE),
falsecascada email.
Uso:
python n8n/_apply_phase2.py # dry-run
python n8n/_apply_phase2.py --apply [--activate]
"""
import argparse
import copy
import json
import os
import sys
import uuid
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from n8n_workflow_lib import load_credentials, N8NClient # noqa: E402
WID = "4UMRwxJdHFfOGHBp"
CF_ID_BRAND = "E6lI9ykWhqpj7Pmi7Qd3"
CODE_EXTRACT_NAME = "Extraer id_contacto_sucursal de Marca"
IF_CF_PRESENT_NAME = "¿cfValue presente?"
HTTP_GET_NAME = "Match directo por id_contacto_sucursal"
IF_GET_OK_NAME = "¿GET de contacto sucursal OK?"
def code_extract_node():
return {
"parameters": {
"jsCode": (
"var c = $('Obtener Contacto Cuenta Origen - SUCURSAL').first().json.contact || {};\n"
"var cfs = c.customFields || [];\n"
"var v = null;\n"
"for (var i = 0; i < cfs.length; i++) {\n"
" if (cfs[i].id === '" + CF_ID_BRAND + "') { v = cfs[i].value; break; }\n"
"}\n"
"return [{ json: { cfValue: v || null } }];"
)
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1376, 416],
"id": str(uuid.uuid4()),
"name": CODE_EXTRACT_NAME,
"notes": "FASE 2. Extrae el valor del CF id_contacto_sucursal del contacto Marca origen.",
}
def if_cf_present_node():
return {
"parameters": {
"conditions": {
"options": {
"caseSensitive": True,
"leftValue": "",
"typeValidation": "loose",
"version": 3,
},
"conditions": [
{
"id": str(uuid.uuid4()),
"leftValue": "={{ $json.cfValue }}",
"rightValue": "",
"operator": {"type": "string", "operation": "notEmpty", "singleValue": True},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1568, 416],
"id": str(uuid.uuid4()),
"name": IF_CF_PRESENT_NAME,
"notes": "true→HTTP GET, false→cascada email",
}
def http_get_node():
return {
"parameters": {
"method": "GET",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('" + CODE_EXTRACT_NAME + "').item.json.cfValue }}",
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization",
"value": "=Bearer {{ $('Datos API Cuenta objetivo - SUCURSAL').item.json['Token/API'] }}"},
]
},
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [1760, 416],
"id": str(uuid.uuid4()),
"name": HTTP_GET_NAME,
"onError": "continueRegularOutput",
"alwaysOutputData": True,
"notes": "GET directo a sucursal por id (el valor del CF). Si 404, IF siguiente cae a cascada.",
}
def if_get_ok_node():
return {
"parameters": {
"conditions": {
"options": {
"caseSensitive": True,
"leftValue": "",
"typeValidation": "loose",
"version": 3,
},
"conditions": [
{
"id": str(uuid.uuid4()),
"leftValue": "={{ $json.contact && $json.contact.id ? 'ok' : '' }}",
"rightValue": "",
"operator": {"type": "string", "operation": "notEmpty", "singleValue": True},
}
],
"combinator": "and",
},
"options": {},
},
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [1952, 416],
"id": str(uuid.uuid4()),
"name": IF_GET_OK_NAME,
"notes": "true→UPDATE (Conseguir Custom Cuenta objetivo - SUCURSAL), false→cascada email",
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
parser.add_argument("--activate", action="store_true")
args = parser.parse_args()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
client = N8NClient(*load_credentials())
wf, backup = client.backup_workflow(WID, label="fase2_pre")
print(f"backup: {backup}")
prev = wf.get("versionId")
print(f"versionId pre: {prev} active: {wf.get('active')} nodes: {len(wf['nodes'])}")
wfm = copy.deepcopy(wf)
for name in (CODE_EXTRACT_NAME, IF_CF_PRESENT_NAME, HTTP_GET_NAME, IF_GET_OK_NAME):
try:
client.assert_idempotent(wfm, name)
except Exception as e:
raise SystemExit(f"Aborto: {e}")
# Insertar nodos
code_node = code_extract_node()
if_cf = if_cf_present_node()
http_get = http_get_node()
if_ok = if_get_ok_node()
for n in (code_node, if_cf, http_get, if_ok):
client.add_node(wfm, n)
# Conexiones:
# Datos API Cuenta objetivo - SUCURSAL → Code Extract
# Code Extract → IF cfValue presente
# true → HTTP GET
# → IF GET OK
# true → Conseguir Custom Cuenta objetivo - SUCURSAL
# false → Buscar Contacto Objetivo - SUCURSAL(mail)
# false → Buscar Contacto Objetivo - SUCURSAL(mail)
client.set_connection(wfm, "Datos API Cuenta objetivo - SUCURSAL", CODE_EXTRACT_NAME)
client.set_connection(wfm, CODE_EXTRACT_NAME, IF_CF_PRESENT_NAME)
client.branch_if(wfm, IF_CF_PRESENT_NAME,
true_target=HTTP_GET_NAME,
false_target="Buscar Contacto Objetivo - SUCURSAL(mail)")
client.set_connection(wfm, HTTP_GET_NAME, IF_GET_OK_NAME)
client.branch_if(wfm, IF_GET_OK_NAME,
true_target="Conseguir Custom Cuenta objetivo - SUCURSAL",
false_target="Buscar Contacto Objetivo - SUCURSAL(mail)")
print(f"nodes post: {len(wfm['nodes'])} (+{len(wfm['nodes'])-len(wf['nodes'])})")
if not args.apply:
res = client.put_workflow(WID, wfm, dry_run=True)
print(f"DRY-RUN. {res['path']}")
return
client.put_workflow(WID, wfm, dry_run=False)
wf2 = client.verify_post(WID,
expected_node_names=[CODE_EXTRACT_NAME, IF_CF_PRESENT_NAME,
HTTP_GET_NAME, IF_GET_OK_NAME],
prev_version_id=prev)
print(f"versionId nuevo: {wf2.get('versionId')} active: {wf2.get('active')}")
if args.activate or not wf2.get("active"):
client.activate(WID)
print("activado")
print("Fase 2 aplicada.")
if __name__ == "__main__":
main()
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Fix post-Fase 2/4: el code `Obtener datos completos de Contacto objetivo -
SUCURSAL` referencia a `Obtener Contacto Cuenta objetivo - SUCURSAL` directamente,
pero en la rama de match por CF ese nodo no se ejecuta (el contacto viene de
`Match directo por id_contacto_sucursal`). Hacemos el code defensivo: probamos
ambos nodos y usamos el que tenga datos.
Idempotente.
"""
import argparse, os, sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT); sys.path.insert(0, os.path.join(ROOT, "scripts"))
from n8n_workflow_lib import load_credentials, N8NClient # noqa: E402
WID = "4UMRwxJdHFfOGHBp"
TARGET = "Obtener datos completos de Contacto objetivo - SUCURSAL"
MARKER = "// FIX FASE 2: input from match by CF or cascade"
NEW_PREAMBLE = (
MARKER + "\n"
"var contactData;\n"
"try {\n"
" contactData = $('Match directo por id_contacto_sucursal').first().json.contact;\n"
"} catch (e) { contactData = null; }\n"
"if (!contactData || !contactData.id) {\n"
" contactData = $('Obtener Contacto Cuenta objetivo - SUCURSAL').first().json.contact;\n"
"}\n"
)
def main():
p = argparse.ArgumentParser()
p.add_argument("--apply", action="store_true")
args = p.parse_args()
if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8")
c = N8NClient(*load_credentials())
wf, b = c.backup_workflow(WID, label="fase2_fix_code_ref")
print(f"backup: {b}")
prev = wf.get("versionId")
n = c.find_node(wf, TARGET)
if not n: raise SystemExit(f"nodo {TARGET!r} no encontrado")
code = n["parameters"].get("jsCode") or ""
if MARKER in code:
print("ya parchado (skip)"); return
old_line = "const contactData = $('Obtener Contacto Cuenta objetivo - SUCURSAL').first().json.contact;"
if old_line not in code:
print("línea esperada no encontrada; revisa manualmente"); print(code[:400]); return
new_code = code.replace(old_line, NEW_PREAMBLE)
n["parameters"]["jsCode"] = new_code
if not args.apply:
res = c.put_workflow(WID, wf, dry_run=True); print(f"DRY-RUN: {res['path']}"); return
c.put_workflow(WID, wf, dry_run=False)
wf2 = c.verify_post(WID, expected_node_names=[TARGET], prev_version_id=prev)
print(f"versionId nuevo: {wf2.get('versionId')} active: {wf2.get('active')}")
if not wf2.get("active"): c.activate(WID); print("activado")
print("OK")
if __name__ == "__main__":
main()
+184
View File
@@ -0,0 +1,184 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Fase 3 — MARCA → SUCURSAL V2: autoenlace bidireccional post-CREATE.
Tras `Crear Contacto - Cuenta Objetivo - MARCA` (nombre engañoso, crea en sucursal):
1) Code `Resolver fieldId y newContactId post-CREATE` extrae el fieldId del CF
en SUCURSAL desde `Conseguir Custom Cuenta objetivo - MARCA1` y el id del
contacto recién creado.
2) HTTP PUT `Enlazar CF en SUCURSAL (autoref)` pone CF=newContactId en sucursal.
3) HTTP PUT `Enlazar CF en MARCA` pone CF=newContactId en el contacto Marca origen.
Después seguimos al `Envio a tienda` original.
"""
import argparse
import copy
import json
import os
import sys
import uuid
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from n8n_workflow_lib import load_credentials, N8NClient # noqa: E402
WID = "4UMRwxJdHFfOGHBp"
CF_ID_BRAND = "E6lI9ykWhqpj7Pmi7Qd3"
CODE_RESOLVE = "Resolver fieldId y newContactId post-CREATE"
PUT_SUC = "Enlazar CF en SUCURSAL (autoref)"
PUT_MARCA = "Enlazar CF en MARCA"
def code_resolve_node():
return {
"parameters": {
"jsCode": (
"var defs = $('Conseguir Custom Cuenta objetivo - MARCA1').first().json.customFields || [];\n"
"var def = defs.find(function(d){ return d.fieldKey === 'contact.id_contacto_sucursal'; });\n"
"var createdContact = ($('Crear Contacto - Cuenta Objetivo - MARCA').first().json || {}).contact || {};\n"
"var newId = createdContact.id;\n"
"return [{ json: { fieldId: def ? def.id : null, newContactId: newId || null } }];"
)
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [3920, 848],
"id": str(uuid.uuid4()),
"name": CODE_RESOLVE,
"notes": "FASE 3. Extrae fieldId del CF en sucursal y el id del contacto recién creado.",
}
def put_sucursal_node():
return {
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $json.newContactId }}",
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization",
"value": "=Bearer {{ $('Datos API Cuenta objetivo - SUCURSAL').item.json['Token/API'] }}"},
{"name": "Content-Type", "value": "application/json"},
]
},
"sendBody": True,
"specifyBody": "json",
"jsonBody": (
"={\n"
" \"customFields\": [\n"
" {\n"
" \"id\": \"{{ $json.fieldId }}\",\n"
" \"key\": \"contact.id_contacto_sucursal\",\n"
" \"field_value\": \"{{ $json.newContactId }}\"\n"
" }\n"
" ]\n"
"}"
),
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [4112, 848],
"id": str(uuid.uuid4()),
"name": PUT_SUC,
"onError": "continueRegularOutput",
"notes": "FASE 3. PUT al contacto sucursal recién creado: CF id_contacto_sucursal = su propio id.",
}
def put_marca_node():
return {
"parameters": {
"method": "PUT",
"url": "=https://services.leadconnectorhq.com/contacts/{{ $('Webhook').item.json.body.contact_id }}",
"sendHeaders": True,
"headerParameters": {
"parameters": [
{"name": "Accept", "value": "application/json"},
{"name": "Version", "value": "2021-07-28"},
{"name": "Authorization",
"value": "=Bearer {{ $('Datos API Cuenta Origen').item.json['Token/API'] }}"},
{"name": "Content-Type", "value": "application/json"},
]
},
"sendBody": True,
"specifyBody": "json",
"jsonBody": (
"={\n"
" \"customFields\": [\n"
" {\n"
" \"id\": \"" + CF_ID_BRAND + "\",\n"
" \"key\": \"contact.id_contacto_sucursal\",\n"
" \"field_value\": \"{{ $('" + CODE_RESOLVE + "').item.json.newContactId }}\"\n"
" }\n"
" ]\n"
"}"
),
"options": {"redirect": {"redirect": {}}},
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [4304, 848],
"id": str(uuid.uuid4()),
"name": PUT_MARCA,
"onError": "continueRegularOutput",
"notes": "FASE 3. PUT al contacto Marca origen: CF id_contacto_sucursal = id del nuevo sucursal.",
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
parser.add_argument("--activate", action="store_true")
args = parser.parse_args()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
client = N8NClient(*load_credentials())
wf, backup = client.backup_workflow(WID, label="fase3_pre")
print(f"backup: {backup}")
prev = wf.get("versionId")
print(f"versionId pre: {prev} active: {wf.get('active')}")
wfm = copy.deepcopy(wf)
for name in (CODE_RESOLVE, PUT_SUC, PUT_MARCA):
client.assert_idempotent(wfm, name)
code_node = code_resolve_node()
put_suc = put_sucursal_node()
put_marca = put_marca_node()
for n in (code_node, put_suc, put_marca):
client.add_node(wfm, n)
# Conexiones: Crear → Code → PUT suc → PUT Marca → Envio a tienda
# antes: Crear → Envio a tienda
client.set_connection(wfm, "Crear Contacto - Cuenta Objetivo - MARCA", CODE_RESOLVE)
client.set_connection(wfm, CODE_RESOLVE, PUT_SUC)
client.set_connection(wfm, PUT_SUC, PUT_MARCA)
client.set_connection(wfm, PUT_MARCA, "Envio a tienda")
print(f"nodes post: {len(wfm['nodes'])} (+{len(wfm['nodes'])-len(wf['nodes'])})")
if not args.apply:
res = client.put_workflow(WID, wfm, dry_run=True)
print(f"DRY-RUN. {res['path']}")
return
client.put_workflow(WID, wfm, dry_run=False)
wf2 = client.verify_post(WID, expected_node_names=[CODE_RESOLVE, PUT_SUC, PUT_MARCA],
prev_version_id=prev)
print(f"versionId nuevo: {wf2.get('versionId')} active: {wf2.get('active')}")
if args.activate or not wf2.get("active"):
client.activate(WID)
print("activado")
print("Fase 3 aplicada.")
if __name__ == "__main__":
main()
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Fase 4 — Renombres cosméticos del workflow Marca→Sucursal V2.
Los nombres con sufijo "MARCA" o "SUCURSAL" en ese workflow son inconsistentes
porque el JSON fue copy-pasted del workflow inverso. Esta fase renombra usando
`rename_node` que actualiza también todas las referencias `$('OLD')` en
parameters y `connections`.
Idempotente: si ya están renombrados, skip por assert_idempotent inverso.
"""
import argparse
import copy
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
sys.path.insert(0, os.path.join(ROOT, "scripts"))
from n8n_workflow_lib import load_credentials, N8NClient, AlreadyExistsError # noqa: E402
WID = "4UMRwxJdHFfOGHBp"
RENAMES = [
# (nombre actual, nombre nuevo)
("Crear Contacto - Cuenta Objetivo - MARCA", "Crear Contacto - Cuenta Objetivo - SUCURSAL"),
("Conseguir Custom Cuenta objetivo - MARCA1", "Conseguir Custom Cuenta objetivo - SUCURSAL (CREATE)"),
("Obtener Contacto Cuenta Origen - SUCURSAL", "Obtener Contacto Cuenta Origen - MARCA"),
("Conseguir Custom Cuenta Origen- SUCURSAL", "Conseguir Custom Cuenta Origen - MARCA"),
("Obtener datos completos de Contacto origen - SUCURSAL", "Obtener datos completos de Contacto origen - MARCA"),
("Obtener el body para crear Contacto - MARCA", "Obtener el body para crear Contacto - SUCURSAL"),
]
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true")
parser.add_argument("--activate", action="store_true")
args = parser.parse_args()
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
client = N8NClient(*load_credentials())
wf, backup = client.backup_workflow(WID, label="fase4_pre")
print(f"backup: {backup}")
prev = wf.get("versionId")
print(f"versionId pre: {prev} active: {wf.get('active')}")
wfm = copy.deepcopy(wf)
applied = 0
expected_post = []
for old, new in RENAMES:
# Si el nodo viejo no existe, asumimos que ya está renombrado (idempotente)
if client.find_node(wfm, old) is None:
if client.find_node(wfm, new) is not None:
print(f" [skip] {old!r}{new!r} (ya renombrado)")
expected_post.append(new)
continue
else:
raise SystemExit(f"FAIL: {old!r} no existe y {new!r} tampoco.")
try:
client.rename_node(wfm, old, new)
print(f" [rename] {old!r}{new!r}")
applied += 1
expected_post.append(new)
except AlreadyExistsError as e:
raise SystemExit(f"FAIL: {e}")
if applied == 0:
print("Nada que renombrar.")
return
if not args.apply:
res = client.put_workflow(WID, wfm, dry_run=True)
print(f"DRY-RUN. {res['path']}")
return
client.put_workflow(WID, wfm, dry_run=False)
wf2 = client.verify_post(WID, expected_node_names=expected_post, prev_version_id=prev)
print(f"versionId nuevo: {wf2.get('versionId')} active: {wf2.get('active')}")
if args.activate or not wf2.get("active"):
client.activate(WID)
print("activado")
print(f"Fase 4 aplicada: {applied} renombres.")
if __name__ == "__main__":
main()
+41
View File
@@ -0,0 +1,41 @@
// DECISION DETERMINISTICA ESTRICTA (Create vs Update)
// Llave: el ID de la opp de SUCURSAL (unico en GHL), guardado como custom field
// "ID Oportunidad Sucursal" en la opp de Marca. Buscamos entre las opps del
// contacto en Marca una cuyo custom field valga exactamente ese id.
// - Si hay match -> UPDATE (esa opp exacta).
// - Si NO hay match -> CREATE (sin fallback por nombre).
const result = $input.first().json;
const opportunities = result.opportunities || [];
const sucursalOppId = $('Datos de Lead').first().json.Oportunidad.opportunity_id;
let action = 'CREATE';
let opportunityId = null;
let matchReason = '';
const match = opportunities.find(o =>
(o.customFields || []).some(cf => {
const v = cf.fieldValueString ?? cf.fieldValue ?? '';
return String(v) === String(sucursalOppId);
})
);
if (match) {
action = 'UPDATE';
opportunityId = match.id;
matchReason = 'Match por ID Oportunidad Sucursal';
} else {
action = 'CREATE';
matchReason = 'Sin match por ID Oportunidad Sucursal -> CREATE (contacto tiene ' + opportunities.length + ' opps)';
}
return [{
json: {
action,
opportunityId,
matchReason,
sucursalOppId,
contactId: $('Set Contact ID Resuelto').first().json.contactId,
totalOppsContacto: opportunities.length
}
}];
+123
View File
@@ -0,0 +1,123 @@
# -*- coding: utf-8 -*-
"""Tercer fix del path cascada del workflow Marca->Sucursal V2 (4UMRwxJdHFfOGHBp).
Tras arreglar el token y el locationId del 'Buscar Contacto Objetivo - SUCURSAL(mail)',
el endpoint responde correctamente con {contacts:[], total:0} cuando no encuentra
por email. Pero el IF siguiente `Si hay mas de un resultado` no maneja el caso
0 resultados:
condiciones actuales: contacts[1] exists AND total > 0 (AND)
true -> Retry telefono
false -> Obtener Contacto Cuenta objetivo - SUCURSAL (BUG: con id undefined)
Cuando total=0, ambas condiciones son false, va a [false] -> `Obtener Contacto`
con `$json.contacts[0].id` = undefined, lo que dispara 403 y termina en CREATE
fallido (si hay duplicate phone) o CREATE exitoso (si es contacto nuevo).
Fix: alinear con la logica del IF `Si hay mas de un resultado1` (el siguiente
en la cadena), que ya tiene la condicion correcta:
condiciones correctas: contacts[1] exists OR total equals 0 (OR)
true (2+ resultados o 0 resultados) -> Retry telefono
false (exactamente 1 resultado) -> Obtener Contacto
Cambio neto: 2 expressions (operator y combinator) en un solo IF. Total nodos
sin cambios.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.n8n_workflow_lib import load_credentials, N8NClient
WID = "4UMRwxJdHFfOGHBp"
TARGET_NODE = "Si hay más de un resultado"
REFERENCE_NODE = "Si hay más de un resultado1" # ya tiene la logica correcta
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Aplicar PUT real (default: dry-run a n8n/dryrun_*.json)")
return ap.parse_args()
def main():
args = parse_args()
api_key, base_url = load_credentials()
client = N8NClient(api_key, base_url)
print(f"[1/6] GET workflow {WID}")
wf = client.get_workflow(WID)
prev_version = wf.get("versionId")
print(f" versionId previo: {prev_version}")
print(f" total nodos : {len(wf.get('nodes') or [])}")
target = next((n for n in wf["nodes"] if n.get("name") == TARGET_NODE), None)
ref = next((n for n in wf["nodes"] if n.get("name") == REFERENCE_NODE), None)
if not target or not ref:
print(f"\nERROR: nodos requeridos no existen. Abortando.")
sys.exit(2)
target_conds = target["parameters"]["conditions"]
ref_conds = ref["parameters"]["conditions"]
print(f"\n[2/6] Diagnostico")
print(f" Target combinator: {target_conds.get('combinator')}")
print(f" Ref combinator: {ref_conds.get('combinator')}")
for i, (tc, rc) in enumerate(zip(target_conds["conditions"], ref_conds["conditions"])):
t_op = tc.get("operator", {}).get("operation")
r_op = rc.get("operator", {}).get("operation")
print(f" cond[{i}]: target.op={t_op} ref.op={r_op}")
# Aplicar misma estructura del ref al target (preservando los ids existentes)
if target_conds.get("combinator") == "or":
is_total_eq_zero = any(
c.get("operator", {}).get("operation") == "equals" and c.get("leftValue", "").endswith("total }}")
for c in target_conds["conditions"]
)
if is_total_eq_zero:
print(f"\n [SKIP] Target ya esta arreglado.")
return
print(f"\n[3/6] Backup fresco")
_, bpath = client.backup_workflow(WID, label="pre_fix_cascada_if_zero_results")
print(f" backup -> {bpath}")
print(f"\n[4/6] Aplicar cambio en memoria")
# Forzar combinator OR
target_conds["combinator"] = "or"
# Cambiar la segunda condicion (total > 0) a (total equals 0)
second = target_conds["conditions"][1]
second["operator"]["operation"] = "equals"
# rightValue ya era 0 (number), se queda igual
print(f" nuevo combinator: {target_conds['combinator']}")
print(f" nuevo cond[1].op: {second['operator']['operation']}")
print(f"\n[5/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
result = client.put_workflow(WID, wf, dry_run=not args.apply)
if not args.apply:
print(f" dry-run dump -> {result}")
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
return
print(f"\n[6/6] Verificar y reactivar")
new_wf = client.verify_post(WID, prev_version_id=prev_version)
target_post = next((n for n in new_wf["nodes"] if n.get("name") == TARGET_NODE), None)
conds_post = target_post["parameters"]["conditions"]
print(f" versionId nuevo : {new_wf.get('versionId')}")
print(f" total nodos : {len(new_wf.get('nodes') or [])}")
print(f" combinator post : {conds_post.get('combinator')}")
print(f" cond[1].op post : {conds_post['conditions'][1]['operator']['operation']}")
if not new_wf.get("active"):
print(" workflow inactivo; reactivando...")
client.activate(WID)
new_wf = client.get_workflow(WID)
print(f" active final : {new_wf.get('active')}")
print("\nOK.")
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
# -*- coding: utf-8 -*-
"""Segundo fix del nodo `Buscar Contacto Objetivo - SUCURSAL(mail)` del
workflow Marca->Sucursal V2 (4UMRwxJdHFfOGHBp).
El primer fix (`_fix_cascada_token_mail.py`) corrigio el header Authorization
para usar la referencia explicita a 'Datos API Cuenta objetivo - SUCURSAL'.
Pero el body sigue con el mismo bug: usa `$json['ID LOCATION BUCEFALO']`
(implicito), que resuelve a undefined porque el $json llega del IF
`¿cfValue presente?` y no contiene ese campo. Resultado: 422 "locationId
can't be undefined".
Patron correcto (usado por `Retry telefono` y `Retry nombre`):
\"locationId\": \"{{ $('Datos API Cuenta objetivo - SUCURSAL').item.json['ID LOCATION BUCEFALO'] }}\"
Cambio neto: 1 expression en el jsonBody del nodo. Total nodos sin cambios.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.n8n_workflow_lib import load_credentials, N8NClient
WID = "4UMRwxJdHFfOGHBp"
TARGET_NODE = "Buscar Contacto Objetivo - SUCURSAL(mail)"
BUGGY_FRAGMENT = '"locationId": "{{ $json[\'ID LOCATION BUCEFALO\'] }}"'
FIXED_FRAGMENT = (
'"locationId": "{{ '
"$('Datos API Cuenta objetivo - SUCURSAL').item.json['ID LOCATION BUCEFALO']"
' }}"'
)
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Aplicar PUT real (default: dry-run a n8n/dryrun_*.json)")
return ap.parse_args()
def main():
args = parse_args()
api_key, base_url = load_credentials()
client = N8NClient(api_key, base_url)
print(f"[1/6] GET workflow {WID}")
wf = client.get_workflow(WID)
prev_version = wf.get("versionId")
print(f" versionId previo: {prev_version}")
print(f" active : {wf.get('active')}")
print(f" total nodos : {len(wf.get('nodes') or [])}")
target = next((n for n in wf["nodes"] if n.get("name") == TARGET_NODE), None)
if not target:
print(f"\nERROR: nodo {TARGET_NODE!r} no existe. Abortando.")
sys.exit(2)
current_body = (target.get("parameters") or {}).get("jsonBody", "")
print(f"\n[2/6] Verificar body actual")
print(f" actual: {current_body}")
if FIXED_FRAGMENT in current_body:
print(f"\n [SKIP] El body ya esta arreglado.")
return
if BUGGY_FRAGMENT not in current_body:
print(f"\nERROR: el body no contiene el fragmento buggy esperado.")
print(f" buggy esperado: {BUGGY_FRAGMENT}")
sys.exit(2)
print(f"\n[3/6] Backup fresco")
_, bpath = client.backup_workflow(WID, label="pre_fix_cascada_locationid_mail")
print(f" backup -> {bpath}")
print(f"\n[4/6] Aplicar cambio en memoria")
new_body = current_body.replace(BUGGY_FRAGMENT, FIXED_FRAGMENT)
target["parameters"]["jsonBody"] = new_body
print(f" nuevo: {new_body}")
print(f"\n[5/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
result = client.put_workflow(WID, wf, dry_run=not args.apply)
if not args.apply:
print(f" dry-run dump -> {result}")
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
return
print(f"\n[6/6] Verificar y reactivar")
new_wf = client.verify_post(WID, prev_version_id=prev_version)
target_post = next((n for n in new_wf["nodes"] if n.get("name") == TARGET_NODE), None)
body_post = (target_post.get("parameters") or {}).get("jsonBody", "")
print(f" versionId nuevo : {new_wf.get('versionId')}")
print(f" total nodos : {len(new_wf.get('nodes') or [])}")
print(f" body coincide : {FIXED_FRAGMENT in body_post}")
if not new_wf.get("active"):
print(" workflow inactivo; reactivando...")
client.activate(WID)
new_wf = client.get_workflow(WID)
print(f" active final : {new_wf.get('active')}")
print("\nOK.")
if __name__ == "__main__":
main()
+109
View File
@@ -0,0 +1,109 @@
# -*- coding: utf-8 -*-
"""Fix del bug de token en la cascada del workflow Marca->Sucursal V2.
El nodo `Buscar Contacto Objetivo - SUCURSAL(mail)` (workflow 4UMRwxJdHFfOGHBp)
usa una expression IMPLICITA para el header Authorization:
=Bearer {{ $json['Token/API'] }}
Esto resuelve al token de MARCA (heredado del set node 'Datos API Cuenta Origen'),
no al de sucursal, por lo que el endpoint POST /contacts/search responde 401.
El nodo siguiente (`Obtener Contacto Cuenta objetivo - SUCURSAL`) recibe
{error: ...} en lugar de {contacts: [...]} y termina rebotando con 403 porque
$json.contacts[0].id es undefined.
Fix: cambiar la expression a la explicita usada por los otros 9 HTTP nodes:
=Bearer {{ $('Datos API Cuenta objetivo - SUCURSAL').item.json['Token/API'] }}
Cambio neto: 1 expression. Total nodos sin cambios (42).
Modo dry-run por defecto; --apply para PUT real + reactivar.
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from scripts.n8n_workflow_lib import load_credentials, N8NClient
WID = "4UMRwxJdHFfOGHBp"
TARGET_NODE = "Buscar Contacto Objetivo - SUCURSAL(mail)"
BUGGY_VALUE = "=Bearer {{ $json['Token/API'] }}"
FIXED_VALUE = "=Bearer {{ $('Datos API Cuenta objetivo - SUCURSAL').item.json['Token/API'] }}"
def parse_args():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true",
help="Aplicar PUT real (default: dry-run a n8n/dryrun_*.json)")
return ap.parse_args()
def main():
args = parse_args()
api_key, base_url = load_credentials()
client = N8NClient(api_key, base_url)
print(f"[1/6] GET workflow {WID}")
wf = client.get_workflow(WID)
prev_version = wf.get("versionId")
print(f" versionId previo: {prev_version}")
print(f" active : {wf.get('active')}")
print(f" total nodos : {len(wf.get('nodes') or [])}")
# Localizar nodo y validar expression actual (defensa contra cambios concurrentes)
target = next((n for n in wf["nodes"] if n.get("name") == TARGET_NODE), None)
if not target:
print(f"\nERROR: nodo {TARGET_NODE!r} no existe. Abortando.")
sys.exit(2)
headers = ((target.get("parameters") or {}).get("headerParameters") or {}).get("parameters") or []
auth_header = next((h for h in headers if h.get("name") == "Authorization"), None)
if not auth_header:
print(f"\nERROR: nodo {TARGET_NODE!r} no tiene header Authorization. Abortando.")
sys.exit(2)
current = auth_header.get("value", "")
print(f"\n[2/6] Verificar expression actual")
print(f" actual : {current}")
print(f" esperada : {BUGGY_VALUE}")
if current == FIXED_VALUE:
print(f"\n [SKIP] La expression ya esta arreglada. Nada que hacer.")
return
if current != BUGGY_VALUE:
print(f"\nERROR: expression actual difiere de la esperada y tampoco esta arreglada.")
print(f" Abortando para no sobrescribir cambios manuales.")
sys.exit(2)
print(f"\n[3/6] Backup fresco")
_, bpath = client.backup_workflow(WID, label="pre_fix_cascada_token_mail")
print(f" backup -> {bpath}")
print(f"\n[4/6] Aplicar cambio en memoria")
auth_header["value"] = FIXED_VALUE
print(f" nuevo: {FIXED_VALUE}")
print(f"\n[5/6] {'APPLY' if args.apply else 'DRY-RUN'} put_workflow")
result = client.put_workflow(WID, wf, dry_run=not args.apply)
if not args.apply:
print(f" dry-run dump -> {result}")
print("\nLISTO. Revisa el JSON. Si todo bien, corre con --apply.")
return
print(f"\n[6/6] Verificar y reactivar")
new_wf = client.verify_post(WID, prev_version_id=prev_version)
# Re-verificar la expression
target_post = next((n for n in new_wf["nodes"] if n.get("name") == TARGET_NODE), None)
headers_post = ((target_post.get("parameters") or {}).get("headerParameters") or {}).get("parameters") or []
auth_post = next((h for h in headers_post if h.get("name") == "Authorization"), None)
print(f" versionId nuevo : {new_wf.get('versionId')}")
print(f" total nodos : {len(new_wf.get('nodes') or [])} (esperado: 42)")
print(f" expression post : {auth_post.get('value') if auth_post else '?'}")
print(f" coincide con fix : {auth_post.get('value') == FIXED_VALUE if auth_post else False}")
if not new_wf.get("active"):
print(" workflow inactivo tras PUT; reactivando...")
client.activate(WID)
new_wf = client.get_workflow(WID)
print(f" active final : {new_wf.get('active')}")
print("\nOK.")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
"""Test puntual del fallback que insertamos:
- Crea contacto en PINOTEPA SIN poblar el CF id_contacto_sucursal.
- Dispara el webhook Sucursal->Marca.
- Espera unos segundos.
- Verifica que el CF en la SUCURSAL quedo poblado == contact.id.
Eso prueba que el IF entro por la rama "false" y el PUT autoenlazo.
- Cleanup: borra el contacto sucursal y cualquier contacto Marca que se haya
creado con CF apuntando al sucursal.
"""
import os
import sys
import time
import json
import datetime
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT_DIR)
sys.path.insert(0, os.path.join(ROOT_DIR, "scripts"))
# Importa helpers del harness E2E
from scripts import n8n_e2e_test as t
PINOTEPA = t.PINOTEPA_LOCATION_ID
BRAND = t.BRAND_LOCATION_ID
WEBHOOK = t.WEBHOOK_URL_SUC_TO_MARCA
CF_KEY = t.CF_KEY
def log(msg):
ts = datetime.datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] {msg}", flush=True)
def main():
cleanup = t.Cleanup()
try:
# 1) Resolver field_id del CF en sucursal (varia por sucursal)
cf_id_branch = t.resolve_cf_id_in_location(PINOTEPA, CF_KEY)
log(f"CF field_id en PINOTEPA: {cf_id_branch}")
assert cf_id_branch, "No se resolvio field_id del CF en sucursal"
# 2) Crear contacto sucursal SIN CF poblado
phone = "+5219991119911"
payload = t._new_contact_payload("FB", phone=phone, email="fbtest@e3.local")
branch_id = t.create_contact(PINOTEPA, payload)
cleanup.register(PINOTEPA, branch_id, "fallback branch")
log(f"Branch contact creado: {branch_id}")
# 3) Sanity: confirmar que el CF arranca VACIO en sucursal
c0 = t.get_contact(PINOTEPA, branch_id)
cf0 = t.cf_value_of(c0, cf_id_branch)
log(f"CF pre-webhook en sucursal: {cf0!r} (esperado: vacio)")
assert not cf0, f"CF deberia estar vacio antes del webhook, valor={cf0!r}"
# 4) Disparar webhook Sucursal -> Marca
webhook_payload = t.build_webhook_payload_suc_to_marca(c0)
s, body = t.fire_webhook(WEBHOOK, webhook_payload)
log(f"Webhook status: {s} body: {body[:100]}")
assert s == 200, f"Webhook fallo: {s}"
# 5) Esperar a que el workflow termine + GHL indexe
log("Esperando 15s para que el fallback PUT corra y GHL indexe...")
time.sleep(15)
# 6) Verificar que el CF en SUCURSAL quedo poblado con branch_id
c1 = t.get_contact(PINOTEPA, branch_id)
cf1 = t.cf_value_of(c1, cf_id_branch)
log(f"CF post-webhook en sucursal: {cf1!r}")
if cf1 == branch_id:
log("[OK] El fallback autoenlazo correctamente: rama false -> PUT funciono.")
else:
log(f"[FAIL] CF post-webhook = {cf1!r}, esperaba {branch_id!r}")
sys.exit(2)
# 7) Buscar contacto Marca creado (deberia existir con CF apuntando)
brand_matches = t._find_brand_with_cf(branch_id)
if brand_matches:
for m in brand_matches:
cleanup.register(BRAND, m["id"], "fallback brand (creado por workflow)")
log(f"[OK] Brand creado: {[m['id'] for m in brand_matches]}")
else:
log("[WARN] No se encontro contacto Marca con CF=branch_id (puede tardar mas en indexar).")
finally:
cleanup.run()
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""Test puntual del fallback post-UPDATE en workflow Marca->Sucursal V2.
Escenario:
- Crear contacto en SUCURSAL (con CF autoref).
- Crear contacto en MARCA SIN CF, mismo phone.
- Disparar webhook Marca->Sucursal.
- El workflow va por cascada phone, encuentra contacto sucursal, UPDATE.
- Tras UPDATE, el nuevo IF detecta cfValue vacio -> autoenlace.
- Verificar:
* CF en MARCA queda poblado con id del contacto sucursal.
* CF en SUCURSAL queda con autoref.
- Cleanup.
"""
import os
import sys
import time
import datetime
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT_DIR)
sys.path.insert(0, os.path.join(ROOT_DIR, "scripts"))
from scripts import n8n_e2e_test as t
def log(msg):
ts = datetime.datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] {msg}", flush=True)
def main():
cleanup = t.Cleanup()
try:
phone = "+5219991122334"
email = "fbpost@e3.local"
# 1) Resolver field_id CF en sucursal (varia por sucursal)
cf_id_branch = t.resolve_cf_id_in_location(t.PINOTEPA_LOCATION_ID, t.CF_KEY)
log(f"CF field_id en PINOTEPA: {cf_id_branch}")
# 2) Crear contacto SUCURSAL con autoref CF
payload_branch = t._new_contact_payload("FBPU", phone=phone, email=email)
branch_id = t.create_contact(t.PINOTEPA_LOCATION_ID, payload_branch)
cleanup.register(t.PINOTEPA_LOCATION_ID, branch_id, "fbpost branch")
log(f"Branch creado: {branch_id}")
# Poblar CF autoref en sucursal
t.update_contact(t.PINOTEPA_LOCATION_ID, branch_id, {
"customFields": [
{"id": cf_id_branch, "key": t.CF_KEY, "field_value": branch_id}
]
})
# 2b) Esperar a que GHL indexe el email en sucursal (el workflow va por cascada mail)
log("Esperando indexacion del email en sucursal...")
suc_token = t.get_token(t.PINOTEPA_LOCATION_ID)
import time as _t
deadline = _t.time() + 30
indexed = False
while _t.time() < deadline:
try:
r = t.gc._request("POST", "/contacts/search", suc_token, json={
"locationId": t.PINOTEPA_LOCATION_ID, "pageLimit": 5, "query": email
})
if any(c.get("id") == branch_id for c in (r.get("contacts") or [])):
indexed = True
break
except Exception:
pass
_t.sleep(2)
log(f" email indexado: {indexed}")
assert indexed, "El email no se indexo en sucursal a tiempo"
# 3) Crear contacto MARCA SIN CF, mismo phone
payload_brand = t._new_contact_payload("FBPU", phone=phone, email=email)
brand_id = t.create_contact(t.BRAND_LOCATION_ID, payload_brand)
cleanup.register(t.BRAND_LOCATION_ID, brand_id, "fbpost brand")
log(f"Brand creado (sin CF): {brand_id}")
# 4) Sanity: confirmar CF Marca vacio
cm0 = t.get_contact(t.BRAND_LOCATION_ID, brand_id)
cf_brand_0 = t.cf_value_of(cm0, t.CF_ID_BRAND)
log(f"CF Marca pre-webhook: {cf_brand_0!r} (esperado: vacio)")
assert not cf_brand_0, f"CF Marca deberia estar vacio, valor={cf_brand_0!r}"
# 5) Disparar webhook Marca->Sucursal
brand_obj = t.get_contact(t.BRAND_LOCATION_ID, brand_id)
webhook_payload = t.build_webhook_payload_marca_to_suc(brand_obj)
s, body = t.fire_webhook(t.WEBHOOK_URL_MARCA_TO_SUC, webhook_payload)
log(f"Webhook status: {s} body: {body[:100]}")
assert s == 200, f"Webhook fallo: {s}"
log("Esperando 18s para que el workflow complete (cascada + UPDATE + autoenlace)...")
time.sleep(18)
# 6) Verificar: CF en MARCA ahora poblado con branch_id
cm1 = t.get_contact(t.BRAND_LOCATION_ID, brand_id)
cf_brand_1 = t.cf_value_of(cm1, t.CF_ID_BRAND)
log(f"CF Marca post-webhook: {cf_brand_1!r}")
if cf_brand_1 == branch_id:
log("[OK] Fallback post-UPDATE: CF Marca quedo enlazado al branch_id.")
else:
log(f"[FAIL] CF Marca = {cf_brand_1!r}, esperaba {branch_id!r}")
sys.exit(2)
# 7) Verificar: CF en SUCURSAL sigue siendo autoref (era branch_id desde antes)
cb1 = t.get_contact(t.PINOTEPA_LOCATION_ID, branch_id)
cf_branch_1 = t.cf_value_of(cb1, cf_id_branch)
log(f"CF Sucursal post-webhook: {cf_branch_1!r}")
if cf_branch_1 == branch_id:
log("[OK] CF Sucursal mantiene autoref.")
else:
log(f"[WARN] CF Sucursal cambio a {cf_branch_1!r}")
finally:
cleanup.run()
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""One-off: agrega filtro `TIPO DE TIENDA != NO DIGITAL` (field 7279) al nodo
Baserow de lookup del Verificador (tabla 750) en los 3 workflows que lo leen.
Operador confirmado empíricamente contra Baserow REST: `not_equal` (2026-05-30).
Idempotente (no duplica el filtro). Backup por workflow. Dry-run por defecto.
python n8n/add_tipo_de_tienda_filter.py # dry-run
python n8n/add_tipo_de_tienda_filter.py --apply # aplica + activa + verifica
"""
import argparse
import copy
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT not in sys.path:
sys.path.insert(0, ROOT)
from scripts.n8n_workflow_lib import load_credentials, N8NClient, PutFailedError # noqa: E402
FILTER_FIELD = 7279
FILTER = {"field": FILTER_FIELD, "operator": "not_equal", "value": "NO DIGITAL"}
# (workflow_id, nodo_objetivo, rol)
TARGETS = [
("4UMRwxJdHFfOGHBp", "Obtener Info de cuenta objetivo - SUCURSAL", "[1604] CRITICO (SUCURSAL contains)"),
("ddUEORBEtZLzsQF2", "Buscar Sucursal en Verificador de Sucursales", "[2004] defensa (SC BUCEFALO)"),
("EuPdIkCORyh0skoB", "Buscar Cuenta Sucursal Bucefalo", "[SUCURSAL] defensa (SC BUCEFALO)"),
]
def get_fields_list(node):
"""Devuelve (fields_list, ok). fields_list es la lista mutable additionalOptions.filters.fields."""
params = node.get("parameters") or {}
ao = params.get("additionalOptions") or {}
filt = ao.get("filters") or {}
fields = filt.get("fields")
return fields if isinstance(fields, list) else None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true")
args = ap.parse_args()
dry = not args.apply
client = N8NClient(*load_credentials())
for wid, node_name, rol in TARGETS:
print("\n" + "=" * 70)
print(f"{wid} {rol}\n nodo: {node_name!r}")
wf = client.get_workflow(wid)
node = client.find_node(wf, node_name)
if node is None:
print(f" ABORTA: nodo no encontrado. Nodos Baserow disponibles:")
for n in wf.get("nodes", []):
if n.get("type") == "n8n-nodes-base.baserow":
print(f" - {n['name']!r}")
sys.exit(1)
fields = get_fields_list(node)
if fields is None:
print(" ABORTA: el nodo no tiene additionalOptions.filters.fields (estructura inesperada)")
sys.exit(1)
existing = [f for f in fields if f.get("field") == FILTER_FIELD]
print(f" filtros actuales: {fields}")
if existing:
print(f" YA tiene filtro field {FILTER_FIELD} -> idempotente, skip. active={wf.get('active')}")
continue
if not dry:
_, bpath = client.backup_workflow(wid, label="add_tipo_filter")
print(f" backup -> {bpath}")
wf2 = copy.deepcopy(wf)
node2 = client.find_node(wf2, node_name)
get_fields_list(node2).append(dict(FILTER))
res = client.put_workflow(wid, wf2, dry_run=dry)
if dry:
print(f" DRY-RUN payload -> {res['path']} (nodos={res['node_count']})")
print(f" filtros que QUEDARIAN: {get_fields_list(node2)}")
continue
# apply ya hecho por put_workflow(dry_run=False)
print(f" PUT aplicado.")
was_active = bool(wf.get("active"))
if was_active:
client.activate(wid)
# verify
wf_live = client.get_workflow(wid)
node_live = client.find_node(wf_live, node_name)
live_fields = get_fields_list(node_live) or []
has = any(f.get("field") == FILTER_FIELD and f.get("operator") == "not_equal" for f in live_fields)
print(f" VERIFY: filtro 7279 presente={has} active={wf_live.get('active')} filtros={live_fields}")
if not has:
print(" !! FALLO verificación — revisar / revertir")
sys.exit(1)
if was_active and not wf_live.get("active"):
print(" !! quedó INACTIVO — reintentar activate")
sys.exit(1)
print("\n" + ("DRY-RUN OK (sin cambios)" if dry else "APLICADO + VERIFICADO en los 3 workflows"))
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More