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:
POST /auth — devolve um token de sessão (~24h) pro EasySAC.GET /contact — recebe o token + telefone do contato e devolve HTML pro iframe.Opcionalmente, o iframe pode disparar ações no EasySAC (ex.: enviar mensagem ao cliente) via window.postMessage.
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
POST /authPOST /auth — emite um token de sessão pro EasySAC.
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"
}
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }
{ "error": "credenciais inválidas" }
| Requisito | Detalhe |
|---|---|
| Validade do token | ≥ 23h (recomendamos 24h). O EasySAC só re-chama /auth quando o iframe_auth_timestamp do agente passa de ~23h. |
| Formato do token | Livre — JWT, opaco, etc. Precisa ser validável no /contact. |
| Identidade | Validar pelo menos o par (login, api_key). Cada org configura o body que envia. |
GET /contact (iframe)GET /contact?token=&celular=&bsuid= — renderiza o HTML que vai dentro do iframe.
| Param | Conteúdo |
|---|---|
token | O JWT/token emitido em /auth. Validar; 401 se inválido/expirado. |
celular | DDI+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. |
Cache-Control: no-storeX-Content-Type-Options: nosniffO painel "Sobre o contato" é estreito (~360px). Design mobile-first, sem JS pesado, sem assets externos. Carregamento < 500ms idealmente.
Como o iframe vive em um domínio diferente do EasySAC, a única forma segura de disparar ações no parent é via window.postMessage.
window.parent.postMessage({
type: 'partner:sendMessage',
phone: '5511975495705',
text: 'Olá! Como posso ajudar?'
}, '*');
{ "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).
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.
O cliente que vai usar sua app parceira precisa configurar 3 coisas no admin do EasySAC:
| Onde | Valor |
|---|---|
organizations.iframe_auth_url | https://seu-app.com/auth |
organizations.iframe_auth_body | {"login":"{login}","api_key":"sua-chave"} |
iframes.url | https://seu-app.com/contact?token={token}&celular={celular} |
agents.iframe_login | Login do agente (vai substituir {login} no body). Pode ser fixo (ROBO) ou o e-mail. |
# 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"
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);
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"})
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();
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,
);
}
É 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).
JWT secret — precisa bater entre /auth e /contact.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.journalctl -u seu-app -n 80.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.
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.
O EasySAC roda em HTTPS, então o browser exige iframes HTTPS (mixed-content). Use HTTPS + Let's Encrypt.