name: painel-corretor-graphql-api
description: Painel do Corretor (Trindade Tecnologia) GraphQL API — how to query leads, get JWT auth, and sync to Supabase
category: supabase
Painel do Corretor — GraphQL API
Context
The Painel do Corretor (Trindade Tecnologia) blocks REST API calls from external origins via Cloudflare. All REST endpoints (/api/crm/negocios, /api/crm/...) return 401/405 when called directly from terminals or scripts. The correct approach was found by inspecting n8n workflows on the VPS.
Key Discovery: GraphQL endpoint
`
POST https://api.paineldocorretor.net/graphql
`
Authentication uses Auth0 JWT Bearer tokens, NOT ApiKey header.
Headers
`
Authorization: Bearer
Content-Type: application/json
origin: https://app2.paineldocorretor.com.br
referer: https://app2.paineldocorretor.com.br/crm/negocios
accept: application/json
`
JWT Token
OAuth2/Auth0 JWT. Contains:
`json
{
"email": "contato@rochasalesseguros.com.br",
"iss": "https://auth.paineldocorretor.com.br/",
"sub": "auth0|6710fef....",
"aud": ["https://paineldocorretor.com.br/api/"],
"scope": "openid profile email offline_access"
}
`
The JWT expires (check exp claim). When expired, GraphQL returns:
`json
{"errors":[{"message":"The current user is not authorized to access this resource.","extensions":{"code":"AUTH_NOT_AUTHORIZED"}}]}
`
CRITICAL BUG in original workflow: Contato.Id is ALWAYS NULL
The original n8n workflow extracted contato { nome email avatar } — MISSING id field! Result: ALL 5181 leads in crm_leads_duplicate have Contato.Id = null. Without contato.id, you cannot call GetContato to fetch telefones, and you cannot match leads to Chatwoot conversations.
FIX: In FooBarQuery, ALWAYS include contato { id nome email avatar telefones }. Then for each lead where contato.id is not null, call GetContato(id) to get telefones.
Two-step workflow: FooBarQuery + GetContato
The Painel API requires TWO separate queries to get complete lead data:
Step 1 — FooBarQuery: Gets all leads with their contato.id (but NOT telefones):
`graphql
contato { id nome avatar email telefones } # id is crucial!
`
Step 2 — GetContato(id): For each unique contato.id, call:
`graphql
query GetContato($id: UUID!) {
contato(id: $id) {
id nome email documento cidade telefones avatar observacao
}
}
`
telefones is [String!]! — array of raw phone strings like ["+551199998888"]. Store as JSON string "[\"+5511...\"]" in Supabase.
In Supabase crm_leads_duplicate:
- -
Contato.Telefonescolumn type istext— must store as JSON string, e.g.'["+551199998888"]' - -
Contato.Idcolumn type istext— stores the UUID of the contact - -
RS - Automação CRM Rocha Sales— cron 1min, GraphQL →crm_leads - -
RS - Automação CRM Rocha Sales (Batch Optimized) NOVA TABELA— same but →crm_leads_duplicate - - Workflow IDs can be found via:
SELECT id, name FROM workflow_entity WHERE name LIKE '%Rocha Sales%' ORDER BY "updatedAt" DESC LIMIT 3; - - Painel REST API returns HTTP 405 (Cloudflare blocked) — unusable
- - Painel GraphQL JWTs expire every ~3 days — all tokens eventually return
AUTH_NOT_AUTHORIZED - - The n8n workflow
RS - Automação CRM Rocha Sales (Batch Optimized) NOVA TABELAsyncs Painel data tocrm_leads_duplicateevery 1 minute via cron - - Supabase Edge Functions using the service role key can read
crm_leads_duplicatewithout REST API calls - - Reads from
crm_leads_duplicate(not Painel API) - - Looks up leads via
chatwoot_painel_mapbypainel_negocio_idmatchingcrm_leads_duplicate.Id - - Updates Chatwoot conversation labels via
PATCH /api/v1/accounts/{accountId}/conversations/{id} - - Deploy:
supabase functions deploy sync-painel-to-chatwoot --project-ref dauftiqcvgaydddoxqhh - - REST API (
ApiKey) blocked by Cloudflare — returns 405 (NOT 401). Use GraphQL only. - - JWT expires — if
AUTH_NOT_AUTHORIZED, the token was revoked or expired. Get a fresh token from the browser (DevTools Network tab → any GraphQL request → Authorization header). - - GraphQL
etiquetasis object type — must query subfields:etiquetas { nome } - - Check
expin JWT before assuming it's valid — n8n workflow JWT may need renewal - -
AUTH_NOT_AUTHORIZEDDOES NOT always mean wrong password — it means the token itself is no longer valid (revoked/expired). A fresh token from the browser will work. - -
contato.idmust be in FooBarQuery — if missing, all leads getContato.Id = nullin Supabase, making GetContato and Chatwoot matching impossible. - - Telefones stored as JSON string —
Contato.Telefonescolumn in Supabase istext. AlwaysJSON.stringify(phonesArray)before upserting, e.g.'[\"+5511...\"]'. - - Paginated GraphQL — Painel uses
take: Npagination. Usetake: 5000ortake: 10000per stage to fetch all leads at once. - - Do NOT add
.eq('source', 'CRM')filter to crm_leads_duplicate queries in Edge Functions — causes the query to return 0 rows even when using the service role key. The filter may work via REST API but not in Edge Runtime. - - Do NOT mix variable names during refactoring — e.g., removing
useLocalLeadsboolean flag but leaving references to it causesis not definedruntime errors. Always use direct null-checks instead:if (localLeads && localLeads.length > 0)is safer than a separate boolean flag. - - Supabase
crm_leads_duplicate.Idmay not matchchatwoot_painel_map.painel_negocio_id—Idis a UUID in crm_leads_duplicate but the map'spainel_negocio_idmay store different ID formats (numeric vs UUID). The full join depends on Chatwoot Search API to resolve conversation IDs by email/phone.
Why two steps? The fooBarQuery only returns contato { id, nome, avatar } without telefones. The GetContato query is separate and must be called per-contact to get the full contact record including telefones.
Fetching GetContato in batch (n8n Code node):
`javascript
// Step 1: collect unique contato.ids from FooBarQuery response
const uniqueIds = [...new Set(leads.filter(l => l.contato?.id).map(l => l.contato.id))];
// Step 2: for each batch of 10 contact IDs, call GetContato
// Wait 300ms between batches to avoid rate limiting
// Store results in a map: { [contatoId]: { telefones, email, documento, cidade } }
`
Working GraphQL Query — Fetch ALL Leads from ALL Stages at Once
IMPORTANT: telefones is a scalar [String!]!, NOT an object. Query it directly, not as telefones { numero }.
`graphql
query FetchAllLeads(
$etapa1: NegociosFilterInput!
$etapa2: NegociosFilterInput!
$etapa3: NegociosFilterInput!
$etapa4: NegociosFilterInput!
$etapa5: NegociosFilterInput!
$etapa6: NegociosFilterInput!
$etapa7: NegociosFilterInput!
$etapa8: NegociosFilterInput!
$etapa9: NegociosFilterInput!
$etapa10: NegociosFilterInput!
) {
negocios_1: negociosElastic(request: $etapa1) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_2: negociosElastic(request: $etapa2) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_3: negociosElastic(request: $etapa3) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_4: negociosElastic(request: $etapa4) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_5: negociosElastic(request: $etapa5) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_6: negociosElastic(request: $etapa6) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_7: negociosElastic(request: $etapa7) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_8: negociosElastic(request: $etapa8) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_9: negociosElastic(request: $etapa9) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
negocios_10: negociosElastic(request: $etapa10) { quantidade items { id nome contato { id nome email telefones } etapa { id nome } produto { nome } etiquetas { nome } valor } }
}
`
Variables — use take: 10000 for each stage:
`python
etapas = [
("novos_leads", "60008110-e9b1-4512-b514-ab1539ea1a1e"),
("qualificacao", "885d4f95-99e5-42a0-8150-b494c42c0e34"),
("remarketing", "6699997d-6978-4679-8b4c-eaab9917fefd"),
("reuniao_agendada", "4646a3e9-b21a-4339-b75b-6997fa3dbb76"),
("proposta_enviada", "4e61480b-b4dc-4b80-b0c8-dd3ddf8e43b2"),
("analise_seguradora","29511da1-1e40-4e9c-8b14-a268ccbdc1d0"),
("aguardando_pgto", "2584da59-4c1e-4fcb-8cba-8374acd896a7"),
("fechado", "df8db361-5b4c-4e66-baac-afcfd9fbe50a"),
("declinado", "70150b84-6864-4cff-aae1-83195a182d8d"),
("sem_interesse", "63f4b9df-55d6-4ff0-a42c-af5ff2ff367b"),
]
variables = {}
for i, (name, etapa_id) in enumerate(etapas, 1):
variables[f"etapa{i}"] = {
"busca": None,
"take": 10000,
"conditions": [{"field": "etapaId", "operator": "", "value": etapa_id}]
}
`
Result (May 2026): 5,181 total leads in crm_leads_duplicate. CRITICAL: ALL 5181 have Contato.Id=null because original workflow never extracted contato.id from FooBarQuery. GetContato must be called separately to populate telefones.
Etapa UUIDs (Rocha Sales pipeline) — VERIFIED May 2026
`
60008110-e9b1-4512-b514-ab1539ea1a1e → Novos Leads (1408 leads)
885d4f95-99e5-42a0-8150-b494c42c0e34 → Qualificação (13 leads)
6699997d-6978-4679-8b4c-eaab9917fefd → Remarketing (814 leads)
4646a3e9-b21a-4339-b75b-6997fa3dbb76 → Reunião Agendada (289 leads)
4e61480b-b4dc-4b80-b0c8-dd3ddf8e43b2 → Proposta Enviada p/cliente (212 leads)
29511da1-1e40-4e9c-8b14-a268ccbdc1d0 → Análise Seguradora (16 leads)
2584da59-4c1e-4fcb-8cba-8374acd896a7 → Aguardando Pagamento (2 leads)
df8db361-5b4c-4e66-baac-afcfd9fbe50a → Negócios Fechados (133 leads)
70150b84-6864-4cff-aae1-83195a182d8d → Declinado (616 leads)
63f4b9df-55d6-4ff0-a42c-af5ff2ff367b → Sem Interesse (1672 leads)
`
Nota: telefones devem ser buscados via GetContato(id) separado — não vêm na FooBarQuery. Contato.Telefones em crm_leads_duplicate é JSON string "[]" (vazio) até GetContato ser chamado. Usa contato.id como join key para Chatwoot.
Rebuilding crm_leads_duplicate from scratch (Node.js script via docker exec n8n)
When the table gets out of sync (e.g. Contato.Id was never populated), rebuild from scratch:
1. Back up: copy rows to crm_leads_duplicate_backup table via Supabase REST API
2. Delete all rows: DELETE FROM crm_leads_duplicate
3. Re-fetch all leads via FooBarQuery (10 etapas × take=5000)
4. Batch-fetch GetContato for all unique contato.ids (batches of 10, 300ms delay)
5. Upsert to Supabase with on_conflict=Id
Key lesson: always include contato.id in FooBarQuery — it is the join key for GetContato AND for matching to Chatwoot conversations.
Token extraction from n8n (when JWT expires)
`bash
Get JWT from n8n workflow nodes (stored as JSON text in Postgres)
docker exec n8n-postgres psql -U n8n -d n8n -c \
"SELECT id, name FROM workflow_entity WHERE name LIKE '%Rocha Sales%' ORDER BY \"updatedAt\" DESC LIMIT 3;"
Then get the JWT from specific workflow:
docker exec n8n-postgres psql -U n8n -d n8n -c \
"SELECT nodes FROM workflow_entity WHERE id = '
| grep -o 'Bearer eyJ[a-zA-Z0-9_-]\.[a-zA-Z0-9_-]\.[a-zA-Z0-9_-]*' | head -1
`
JWT expiry: Tokens expire ~3 days. When expired, GraphQL returns:
`json
{"errors":[{"message":"The current user is not authorized to access this resource.","extensions":{"code":"AUTH_NOT_AUTHORIZED"}}]}
`
Get a fresh token by logging into the Painel in a browser and copying the Authorization header, OR via Auth0 password grant.
Getting a fresh JWT
`bash
curl -s -X POST "https://auth.paineldocorretor.com.br/oauth/token" \
-H "Content-Type: application/json" \
-d '{
"grant_type": "password",
"username": "contato@rochasalesseguros.com.br",
"password": "
"scope": "openid profile email offline_access"
}'
`
Or inspect n8n Postgres: docker exec n8n-postgres psql -U n8n -d n8n -c "SELECT name, nodes FROM workflow_entity;" — JWT stored in workflow node JSON.
Supabase sync target (n8n workflow)
`
POST https://dauftiqcvgaydddoxqhh.supabase.co/rest/v1/crm_leads?on_conflict=Id
Prefer: resolution=merge-duplicates
apikey:
`
Relevant n8n workflows on VPS
Edge Functions: Use crm_leads_duplicate as Primary Data Source
IMPORTANT: When writing Supabase Edge Functions that need Painel data, do NOT call the Painel API directly (REST is 405-blocked, GraphQL JWTs expire and return AUTH_NOT_AUTHORIZED). Instead, read from the crm_leads_duplicate table which is synced every minute by n8n. This table already has 999+ CRM leads with complete lead data.
Why crm_leads_duplicate is the reliable source for Edge Functions
Querying crm_leads_duplicate from Edge Functions (Deno)
`typescript
const { data: localLeads, error: localError } = await supabase
.from('crm_leads_duplicate')
.select('Id, Nome, Etapa, Contato.Email, Contato.Telefones')
// Do NOT add .eq('source', 'CRM') filter — causes query to return 0 rows even with service role key
if (localError) throw new Error(Failed to fetch leads: ${localError.message});
`
Etapa normalization for crm_leads_duplicate display names
The Etapa column in crm_leads_duplicate stores human-readable display names, NOT UUID IDs. Normalize them before comparing:
`typescript
const ETAPA_DISPLAY_MAP: Record
'Novos Leads': 'novos_leads',
'Qualificação': 'qualificacao',
'Remarketing': 'remarketing',
'Reunião Agendada': 'reuniao_agendada',
'Proposta Enviada p/cliente':'proposta_enviada',
'Análise Seguradora': 'analise_seguradora',
'Aguardando Pagamento': 'aguardando_pagamento',
'Negócios Fechados': 'fechado',
'Declinado': 'declinado',
'Sem Interesse': 'sem_interesse',
};
function normalizeEtapa(etapa: string): string {
const key = ETAPA_DISPLAY_MAP[etapa] || etapa.toLowerCase().replace(/\s+/g, '_').replace(/[äâàáã]/g, 'a').replace(/[ëêèé]/g, 'e').replace(/[ïîìí]/g, 'i').replace(/[öôòóõ]/g, 'o').replace(/[üûùú]/g, 'u');
return key;
}
`
chatwoot_painel_map table structure
`sql
-- 1000 rows, maps Painel negocio IDs → Chatwoot conversations
chatwoot_painel_map:
- id: UUID (primary key)
- painel_negocio_id: UUID (Painel negocio UUID from crm_leads_duplicate.Id)
- chatwoot_conversation_id: INTEGER (NULL for most — needs Chatwoot Search API lookup)
- etapa: TEXT (normalized API key, e.g. 'novos_leads')
- source: TEXT ('CRM' or 'vps-direct')
`
CRITICAL LIMITATION: All 1000 entries in chatwoot_painel_map have chatwoot_conversation_id = NULL. The painel_negocio_id values do NOT directly match crm_leads_duplicate.Id — they may be UUID-to-numeric ID mismatch. This means leads cannot currently be matched to Chatwoot conversations without first doing a Chatwoot Search API lookup by email.
Chatwoot labels (already exist)
`
etapa-novos_leads, etapa-qualificacao, etapa-remarketing, etapa-reuniao_agendada,
etapa-proposta_enviada, etapa-analise_seguradora, etapa-aguardando_pagamento,
etapa-fechado, etapa-declinado, etapa-sem_interesse
`
Edge Function sync-painel-to-chatwoot (Deno)
File: supabase/functions/sync-painel-to-chatwoot/index.ts