Iframe parceiro EasySAC

Documentação para construir uma aplicação que se conecta ao painel "Sobre o contato" do EasySAC.

Visão geral

O EasySAC é uma plataforma omnichannel de SAC. No painel "Sobre o contato", ele renderiza iframes de aplicações parceiras pra mostrar dados / ações relacionadas ao contato atual.

Pra ser uma aplicação parceira, você precisa expor dois endpoints HTTP:

Opcionalmente, o iframe pode disparar ações no EasySAC (ex.: enviar mensagem ao cliente) via window.postMessage.

Fluxo completo

1. Agente abre um chat no EasySAC
   └─► EasySAC vê que precisa renderizar o iframe parceiro

2. (uma vez a cada ~23h por agente)
   EasySAC ──POST /auth──► seu app
                           {"login":"<iframe_login do agente>",
                            "api_key":"<configurado por org>"}
   seu app ──{"token":"..."}──► EasySAC

3. EasySAC monta a URL final do iframe substituindo placeholders:
   https://seu-app.com/contact?token=<jwt>&celular=5511975495705

4. Browser do agente ──GET /contact?token=...&celular=...──► seu app
                       ◄── HTML do painel ──

5. (opcional) Agente interage no iframe
   iframe ──postMessage({type:'partner:sendMessage',text,...})──► parent EasySAC
   parent  ──chat-send-message interno──► WhatsApp do cliente

Endpoint 1: POST /auth

POST /auth — emite um token de sessão pro EasySAC.

Request

O body é configurável por organização no EasySAC (organizations.iframe_auth_body). O EasySAC substitui o placeholder {login} pelo agents.iframe_login.

Body recomendado:

{
  "login": "ROBO",
  "api_key": "chave-compartilhada-da-organizacao"
}

Response 200

{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }

Response 401 / 400

{ "error": "credenciais inválidas" }
RequisitoDetalhe
Validade do token≥ 23h (recomendamos 24h). O EasySAC só re-chama /auth quando o iframe_auth_timestamp do agente passa de ~23h.
Formato do tokenLivre — JWT, opaco, etc. Precisa ser validável no /contact.
IdentidadeValidar pelo menos o par (login, api_key). Cada org configura o body que envia.

Endpoint 2: GET /contact (iframe)

GET /contact?token=&celular=&bsuid= — renderiza o HTML que vai dentro do iframe.

Query params

ParamConteúdo
tokenO JWT/token emitido em /auth. Validar; 401 se inválido/expirado.
celularDDI+DDD+número, ex.: 5511975495705. Pode estar vazio em casos de contato sem WhatsApp.
bsuid(Roadmap jun/2026) Identificador alternativo quando o contato não tem celular. Trate como opcional hoje.

Cabeçalhos recomendados

Layout

O painel "Sobre o contato" é estreito (~360px). Design mobile-first, sem JS pesado, sem assets externos. Carregamento < 500ms idealmente.

Comunicação iframe → parent (postMessage)

Como o iframe vive em um domínio diferente do EasySAC, a única forma segura de disparar ações no parent é via window.postMessage.

Iframe envia

window.parent.postMessage({
  type: 'partner:sendMessage',
  phone: '5511975495705',
  text: 'Olá! Como posso ajudar?'
}, '*');

Parent (EasySAC) responde (ack opcional)

{ "type": "partner:sendMessage:ack", "ok": true }
{ "type": "partner:sendMessage:ack", "ok": false, "error": "no_active_chat" }

Outras ações futuras seguem o mesmo padrão (partner:<acao> + ack partner:<acao>:ack).

Importante: o parent valida event.origin contra a allowlist de iframes cadastrados na organização. Garanta que iframes.url e organizations.iframe_auth_url apontam pro mesmo origin da sua app.

Cadastro no EasySAC

O cliente que vai usar sua app parceira precisa configurar 3 coisas no admin do EasySAC:

OndeValor
organizations.iframe_auth_urlhttps://seu-app.com/auth
organizations.iframe_auth_body{"login":"{login}","api_key":"sua-chave"}
iframes.urlhttps://seu-app.com/contact?token={token}&celular={celular}
agents.iframe_loginLogin do agente (vai substituir {login} no body). Pode ser fixo (ROBO) ou o e-mail.

Exemplos de implementação

Testar autenticação e iframe via curl

# 1) pegar token
TOKEN=$(curl -s -X POST https://seu-app.com/auth \
  -H 'Content-Type: application/json' \
  -d '{"login":"ROBO","api_key":"sua-chave"}' | jq -r .token)

# 2) abrir o iframe
open "https://seu-app.com/contact?token=$TOKEN&celular=5511975495705"

Esqueleto Node.js (Express)

import express from 'express';
import jwt from 'jsonwebtoken';

const app = express();
app.use(express.json());

const SECRET = process.env.JWT_SECRET; // >=32 chars
const API_KEY = process.env.PARTNER_API_KEY;

app.post('/auth', (req, res) => {
  const { login, api_key } = req.body || {};
  if (!login || api_key !== API_KEY) {
    return res.status(401).json({ error: 'credenciais inválidas' });
  }
  const token = jwt.sign({ sub: login }, SECRET, { expiresIn: '24h' });
  res.json({ token });
});

app.get('/contact', (req, res) => {
  res.set('Cache-Control', 'no-store');
  try {
    jwt.verify(req.query.token, SECRET);
  } catch {
    return res.status(401).type('html').send('<h1>Token inválido</h1>');
  }
  const phone = req.query.celular || req.query.bsuid || '—';
  res.type('html').send(`
    <!doctype html><meta charset="utf-8">
    <body style="font-family:sans-serif;padding:12px">
      <h2>${phone}</h2>
      <button onclick="window.parent.postMessage({type:'partner:sendMessage',phone:'${phone}',text:'Olá!'},'*')">
        Enviar Olá
      </button>
    </body>
  `);
});

app.listen(3000);

Esqueleto Python (FastAPI + PyJWT)

from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse
import jwt, os

app = FastAPI()
SECRET = os.environ["JWT_SECRET"]      # >=32 chars
API_KEY = os.environ["PARTNER_API_KEY"]

@app.post("/auth")
async def auth(req: Request):
    body = await req.json()
    if body.get("login") and body.get("api_key") == API_KEY:
        token = jwt.encode({"sub": body["login"]}, SECRET, algorithm="HS256")
        return {"token": token}
    raise HTTPException(401, "credenciais inválidas")

@app.get("/contact", response_class=HTMLResponse)
def contact(token: str, celular: str | None = None, bsuid: str | None = None):
    try:
        jwt.decode(token, SECRET, algorithms=["HS256"])
    except jwt.PyJWTError:
        return HTMLResponse("<h1>Token inválido</h1>", status_code=401)
    phone = celular or bsuid or "—"
    return HTMLResponse(f"""
        <!doctype html><meta charset="utf-8">
        <body style="font-family:sans-serif;padding:12px">
          <h2>{phone}</h2>
          <button onclick="window.parent.postMessage({{type:'partner:sendMessage',phone:'{phone}',text:'Olá!'}},'*')">Enviar Olá</button>
        </body>
    """, headers={"Cache-Control": "no-store"})

Esqueleto .NET 8 (Minimal API)

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

var b = WebApplication.CreateBuilder(args);
var app = b.Build();

var secret = Environment.GetEnvironmentVariable("JWT_SECRET")!;
var apiKey = Environment.GetEnvironmentVariable("PARTNER_API_KEY")!;
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));

app.MapPost("/auth", async (HttpContext ctx) =>
{
    var b = await ctx.Request.ReadFromJsonAsync<Dictionary<string,string>>();
    if (b is null || !b.TryGetValue("login", out var login) || b.GetValueOrDefault("api_key") != apiKey)
        return Results.Json(new { error = "credenciais inválidas" }, statusCode: 401);

    var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
    var tok = new JwtSecurityToken(claims: new[] { new Claim("sub", login) },
        expires: DateTime.UtcNow.AddHours(24), signingCredentials: creds);
    return Results.Json(new { token = new JwtSecurityTokenHandler().WriteToken(tok) });
});

app.MapGet("/contact", (HttpContext ctx, string token, string? celular, string? bsuid) =>
{
    ctx.Response.Headers["Cache-Control"] = "no-store";
    var p = new TokenValidationParameters { ValidateIssuer=false, ValidateAudience=false, IssuerSigningKey=key };
    try { new JwtSecurityTokenHandler().ValidateToken(token, p, out _); }
    catch { return Results.Content("<h1>Token inválido</h1>", "text/html", null, 401); }
    var phone = celular ?? bsuid ?? "—";
    return Results.Content($"""
        <!doctype html><meta charset="utf-8">
        <body style="font-family:sans-serif;padding:12px">
          <h2>{phone}</h2>
          <button onclick="window.parent.postMessage({{type:'partner:sendMessage',phone:'{phone}',text:'Olá!'}},'*')">Enviar Olá</button>
        </body>
    """, "text/html", null, 200);
});

app.Run();

Listener no parent (Angular do EasySAC)

Trecho a ser adicionado no componente Angular do chat ativo no EasySAC. Já está implementado no nosso ambiente, mas serve como referência pra outros parents:

private partnerOrigins: string[] = []; // origens cadastradas em organizations.iframe_auth_url

ngOnInit() {
  this.partnerOrigins = this.loadFromGetIframes();
  window.addEventListener('message', this.onPartnerMessage);
}

ngOnDestroy() {
  window.removeEventListener('message', this.onPartnerMessage);
}

onPartnerMessage = (e: MessageEvent) => {
  if (!this.partnerOrigins.includes(e.origin)) return;
  if (e.data?.type !== 'partner:sendMessage') return;
  if (!this.chat?.id) return this.ack(e, false, 'no_active_chat');

  const text = String(e.data.text ?? '').trim();
  if (!text) return this.ack(e, false, 'empty_text');

  this.sendMessage(text).then(
    () => this.ack(e, true),
    err => this.ack(e, false, err?.message),
  );
};

private ack(e: MessageEvent, ok: boolean, error?: string) {
  (e.source as Window | null)?.postMessage(
    { type: 'partner:sendMessage:ack', ok, error }, e.origin,
  );
}

FAQ

O token vai na URL — isso é seguro?

É como o EasySAC envia. Mitigações no app parceiro: Cache-Control: no-store, CSP restritiva, não logar a querystring, validade do token curta (24h).

O agente vê "Token inválido". O que verificar?

  1. Confira o JWT secret — precisa bater entre /auth e /contact.
  2. O EasySAC cacheia o token por ~23h no campo agents.iframe_token. Se você alterou o iframe_auth_url recentemente, talvez o agente ainda esteja usando o token antigo. Force re-auth zerando iframe_token + iframe_auth_timestamp no banco.
  3. Veja journalctl -u seu-app -n 80.

Posso registrar mais de um iframe parceiro pra mesma organização?

Sim. iframes aceita múltiplas linhas por org_id. Mas só existe um iframe_auth_url por org — todos os iframes compartilham o mesmo token.

Como passar o ID do chat no iframe?

Hoje o EasySAC só passa {token} e {celular}. Pra ações via postMessage, não precisa do chat_id — o parent já sabe qual chat está ativo no contexto.

Posso usar HTTP em vez de HTTPS?

O EasySAC roda em HTTPS, então o browser exige iframes HTTPS (mixed-content). Use HTTPS + Let's Encrypt.