PHIZ Business Messaging API
Autenticação por conta de comerciante, envio de mensagens para telefone ou PHIZ ID e webhooks de mensagens e status. Inspirada na Cloud API da Meta e pensada como base da futura Marketing API.
01Resumo
Este documento propõe uma API pública para que sistemas externos (Blip, Salesforce, CRMs ou sistemas próprios do comerciante) conversem com usuários PHIZ em nome de um comerciante. A proposta tem quatro partes:
- Autenticação OAuth 2.0 (client credentials) com credenciais emitidas para a conta de comerciante, não mais para um canal.
- Envio de mensagens para um número de telefone (E.164) ou um PHIZ ID, diretamente em nome do comerciante. Usar um canal como remetente é opcional.
- Webhooks assinados que entregam as mensagens recebidas dos usuários e cada mudança de status das mensagens enviadas.
- Uma Área do Desenvolvedor no Open Platform, no nível do comerciante, onde ele cadastra cada sistema externo que vai usar a API (por exemplo, Blip ou Salesforce) como uma integração. Cada integração tem
client_ide secret próprios, webhook e histórico de entregas. Integrações não têm relação com Miniapps.
Os contratos de mensagem e webhook reproduzem a estrutura da Cloud API da Meta, trocando apenas os nomes do domínio. Quem já integra com o WhatsApp Business Platform adapta a integração mudando URLs, credenciais e alguns nomes de campo.
02Contexto: como funciona hoje
Levantamento feito na documentação atual (Apifox, módulo Authentication) e no Open Platform (open.phiz.chat).
Autenticação atual
O token é obtido em POST /gateway/openapi/auth com app_id, app_secret, description (obrigatório) e grant_type: "user_credentials". A resposta traz um access token de cerca de 2 horas e um refresh token de cerca de 90 dias, renovado em POST /gateway/openapi/token/refresh.
POST /gateway/openapi/auth
Content-Type: application/json
{
"app_id": "69c37cb1821121af34201f48",
"app_secret": "94B18B920D19463C7264BD0BDE30C31…",
"description": "push news",
"grant_type": "user_credentials",
"device_fingerprint": ""
}
200 OK
{
"code": 200,
"msg": "success",
"data": {
"scope": "channel",
"status": "active",
"access_token": "at_1770343926041_1Y3iz6XN…",
"token_type": "Bearer",
"expires_in": 7199,
"refresh_token": "rt_1770343926041_mxc7RV9…",
"access_token_expires_at": "2026-02-06T04:…",
"refresh_token_expires_at": "2026-05-07T02:…",
"created_at": "2026-02-05T10:15:23.672+00:00"
},
"success": true
}Onde ficam as credenciais
No Open Platform, cada canal tem a página Canal › Configuração de Desenvolvimento com AppID e AppSecret. O secret não é exibido e só pode ser redefinido. O canal também oferece, em Robô de IA, o modo de resposta “Phiz Business API”, que encaminha as conversas do canal para envio e callbacks de webhook da Business Message API.
No nível do comerciante já existem o ID do Comerciante (UUID), Gerenciamento da equipe com funções, membros e registro de operações. Esses três elementos servem de base para a nova área.
O que muda com esta RFC
Cada linha mostra como funciona hoje, por que isso atrapalha, o que esta proposta coloca no lugar e um caso de uso concreto.
| Como é hoje | Por que atrapalha | Com esta RFC | Caso de uso |
|---|---|---|---|
| Para falar com o usuário pela API, o comerciante precisa de um canal. | Quem não tem canal, ou quer falar como a própria empresa, não consegue mandar mensagem direta ao usuário. | O comerciante manda mensagem direta, como ele mesmo. Usar o canal passa a ser uma opção. | Uma farmácia sem canal avisa a cliente Maria, pelo telefone dela, que o pedido está pronto para retirada. A mensagem chega numa conversa direta com a farmácia. |
| Cada canal tem seu próprio AppID e secret. | Um comerciante com 5 canais administra 5 credenciais, e a Blip precisa de um token por canal. | Uma credencial por integração, na conta do comerciante, com acesso ao envio direto e aos canais escolhidos. | A Loja Exemplo tem 3 canais e usa a Blip. Hoje cadastra 3 AppIDs na Blip; com a RFC, cria uma integração “Blip” e marca envio direto e os 3 canais. |
| “Redefinir” invalida o secret na hora. | A integração fica fora do ar até alguém atualizar o secret no sistema externo. | Dois secrets válidos ao mesmo tempo: o novo entra e o antigo continua valendo por um período (padrão 24 h). | O secret do Salesforce apareceu num log. O time gera um novo, atualiza o Salesforce à tarde e revoga o antigo no fim do dia, sem nenhuma mensagem perdida. |
O login usa grant_type: user_credentials e exige o campo description. | Ferramentas que já falam OAuth, como o Salesforce, não conseguem se conectar sem código sob medida. | OAuth 2.0 padrão (client_credentials), que essas ferramentas suportam de fábrica. | O administrador do Salesforce conecta a PHIZ em Named Credentials preenchendo só a URL do token, o client_id e o secret, sem desenvolvedor. |
| Existe um refresh token válido por cerca de 90 dias. | É mais um segredo de longa duração para guardar, sem ganho numa integração entre servidores. | Token de 1 h, sem refresh token; quando expira, o sistema pede outro. | A Blip renova o token sozinha a cada hora com o mesmo client_id e secret. Não existe refresh token que possa vencer num fim de semana e derrubar o atendimento. |
| Não há tela para webhook, logs ou várias integrações. | O comerciante não consegue configurar o webhook nem descobrir sozinho por que uma integração parou. | Área do Desenvolvedor com integrações separadas, webhook, histórico de entregas e logs. | As respostas dos clientes param de chegar na Blip. O comerciante abre o histórico de entregas, vê que o webhook responde erro 500 desde as 10h e, depois da correção, reenvia os eventos. |
03Referência: a experiência no WhatsApp
Esta RFC replica no PHIZ o modelo da WhatsApp Business Platform. Para que todos (Brasil e China) tenham a mesma imagem do que estamos copiando, esta seção mostra a jornada de um usuário brasileiro no WhatsApp: como cria a conta, como autoriza uma empresa, como a mensagem chega e o que pode fazer com ela. Exemplo: comerciante PicPay, usuária Ana Souza, em São Paulo.
São ilustrações, não a interface oficial do WhatsApp. O texto está em português, como o usuário vê no app. Os códigos (A2, O1, C3…) seguem o canvas de Design que deu origem a esta seção. Clique em uma imagem para abrir em tamanho real.
Caminho da mensagem
A empresa não fala direto com o WhatsApp: o sistema dela (CRM, atendimento) chama um BSP (Blip, Zenvia, Twilio, Infobip), que chama a Cloud API da Meta, que entrega no celular. Respostas, cliques e status voltam por webhook pelo mesmo caminho. No PHIZ, a PHIZ Business Messaging API ocupa o lugar da Cloud API e a conta de comerciante (merchant_id) ocupa o lugar da WABA (ver a equivalência na seção 06).
Cadastro do usuário
No WhatsApp, a conta é o número de telefone, verificado por SMS. Não existe pedido de amizade: quem tem o número consegue escrever. Empresas e família ficam na mesma caixa de entrada, por isso o controle sobre mensagens de empresas é rígido. Desde 2026 o WhatsApp oferece nomes de usuário e identificadores por empresa (BSUID) no lugar do telefone, o que conversa com o uso do PHIZ ID nesta RFC.
Autorização do usuário (opt-in)
Antes de uma empresa iniciar uma conversa, o usuário precisa ter autorizado aquela empresa. Há quatro formas comuns: no app da empresa (O1), no cadastro ou checkout (O2), quando o próprio usuário escreve primeiro, o que vale só para aquele atendimento (O3), e respondendo “sim” dentro do chat (O4). A boa prática é separar por finalidade: autorizar alertas da conta não autoriza ofertas.
A Meta tem a política, mas não valida o opt-in. Não há campo nem endpoint para comprovar a autorização; a Meta confia na empresa e só reage depois, por bloqueios, denúncias e nota de qualidade. Já o opt-out é aplicado pela plataforma: bloqueio, “parar ofertas” e limite de marketing fazem o envio falhar. Mesmo assim, pela LGPD cabe à empresa provar o consentimento e respeitar a revogação, então o registro (quem, comerciante, categoria, origem, data, texto exibido, revogação) é obrigatório na prática.
O que o usuário recebe
Mensagens iniciadas pela empresa usam templates aprovados, em quatro categorias: utilidade (pagamentos, contas), marketing, autenticação (códigos) e serviço. Cada categoria depende do opt-in correspondente.
O que o usuário pode fazer
O usuário responde (texto, botões, lista, formulário), consulta o perfil da empresa ou retira a autorização. Cada ação gera, ou não, um evento para a empresa.
| Ação do usuário | No WhatsApp | No PHIZ |
|---|---|---|
| Responde com texto ou mídia | Webhook messages; abre janela de 24 h | Evento message (seção 10) |
| Toca em botão ou lista | button_reply / list_reply | Evento message com o id escolhido |
| Para ofertas | Envios de marketing falham | Proposta: a plataforma bloqueia a categoria para aquele merchant_id |
| Bloqueia ou denuncia | Todos os envios falham; nota de qualidade cai | Proposta: erro no envio e sinal de qualidade (ver Q3) |
| Silencia ou arquiva | Invisível para a empresa | Igual |
O que levamos para o PHIZ
- A identidade da empresa em todo o fluxo é o ID do Comerciante (
merchant_id): é ele que recebe o opt-in, o bloqueio e a nota de qualidade. Não existe um ID de desenvolvedor separado; integrações e apps apenas agem em nome do comerciante. - Consentimento por comerciante e por categoria, com opt-out aplicado pela plataforma (ver Política de envio, seção 09).
- Sinais do usuário (bloquear, denunciar, parar ofertas) alimentando limites de envio do comerciante.
- Formulários dentro do chat (Flows) como referência para mini apps.
04Opt-in no PhizChat: no app e na empresa
A seção 03 mostrou que no WhatsApp o opt-in quase sempre acontece fora do app e que a Meta não o valida. Esta seção propõe como o usuário autoriza uma conta business no PhizChat, por dois caminhos que terminam nos mesmos controles de opt-out:
- Fluxo P — dentro do PhizChat: o usuário aceita a conta business no próprio app. A plataforma guarda a prova.
- Fluxo Q — na empresa, fora do PhizChat: o usuário autorizou no app, site ou loja do comerciante, e o comerciante inicia a conversa.
São propostas ilustrativas, com o PicPay como comerciante de exemplo. Os códigos (P0…P8, Q0…Q5) seguem o canvas de Design. Clique em uma imagem para abrir em tamanho real.
Fluxo P · Opt-in dentro do PhizChat
O usuário chega à conta business por link de SMS ou e-mail, QR code, app ou site do comerciante, ou pela busca no app. Se não tem o Phiz, vê uma página H5 só com o perfil e o download; o aceite nunca acontece fora do app. Depois de instalar e entrar, um deep link diferido o leva ao mesmo chat, que abre bloqueado até o aceite. Ao aceitar, o PhizChat registra o consentimento (usuário, merchant_id, data, ponto de entrada), envia o template de boas-vindas, se houver, e libera o chat.
Depois do aceite, o perfil mostra o status e a data do consentimento. O usuário pode revogar (o chat volta ao estado bloqueado e pode ser autorizado de novo) ou bloquear (reaproveita o bloqueio atual; a API continua aceitando chamadas, mas devolve o status bloqueado).
Fluxo Q · Opt-in coletado pela empresa, fora do PhizChat
A maioria das empresas brasileiras já coleta consentimento nos próprios canais. No fluxo Q, o usuário ativa “mensagens no PhizChat” no app do PicPay; o PicPay guarda a prova e envia o template pela API. A API confere se o destinatário é usuário do Phiz e se não bloqueou nem revogou; no modelo B, confere também se há consentimento registrado para aquela categoria. No app, a primeira mensagem chega com um cartão de origem do consentimento (o que foi autorizado, onde e quando), e o usuário pode continuar recebendo, parar ou denunciar.
P × Q
| P · opt-in no PhizChat | Q · opt-in na empresa | |
|---|---|---|
| Quem coleta | PhizChat (Aceitar) | O comerciante (app, site, loja) |
| Onde fica a prova | PhizChat | CRM do comerciante (modelo A) · também no PHIZ (modelo B) |
| Primeira conversa | Bloqueada até o aceite | Abre com a mensagem e a origem do consentimento |
| Opt-out | Igual nos dois: revogar, bloquear, denunciar — aplicado pelo PhizChat | |
Decisões em aberto
- Modelo A × B no fluxo Q (ver Q1): só a política, como a Meta, ou registro de consentimento no PHIZ conferido a cada envio. O cartão de origem (Q3) e o status por categoria (Q4) completos dependem do modelo B.
- Entrega antes do aceite no fluxo Q: a proposta entrega a primeira mensagem com o cartão de origem; a alternativa é bloquear o chat até o aceite, como no fluxo P.
- Categorias de consentimento (alertas da conta, ofertas, códigos) e o que o app mostra ao usuário.
- Nomes técnicos —
/v1/merchants/{merchant_id}/consents, o webhookconsent_updatee os códigos de erro — são provisórios, para discussão.
05Objetivos e não-objetivos
Objetivos
- Credenciais ligadas à conta de comerciante, e não mais a cada canal. O comerciante cria uma integração para cada sistema externo (por exemplo, uma para a Blip e outra para o Salesforce). Cada integração recebe seu próprio
client_ide secret, e o comerciante define o que ela pode fazer (enviar mensagens, consultar status, configurar webhook) e por quais remetentes pode falar: o próprio comerciante e, se ele quiser, seus canais. - OAuth 2.0 padrão (client credentials), compatível com bibliotecas e plataformas de mercado.
- Enviar mensagens para telefone ou PHIZ ID e receber respostas e status por webhook assinado.
- Compatibilidade estrutural com a Cloud API da Meta, para que integrações existentes migrem com pouco código.
- Publicar a especificação da API em formato OpenAPI 3.1 (o padrão do Swagger). É um arquivo que descreve todas as rotas, campos, respostas e webhooks, e funciona como acordo entre o time que constrói a API e quem integra com ela. Com ele, parceiros geram documentação e código e testam antes da entrega.
Fora do escopo desta RFC
- Templates, campanhas e segmentação (Marketing API, fases futuras).
- Preço e cobrança por conversa.
- Grupos, chamadas de voz e vídeo.
- SDKs oficiais (podem vir depois, gerados a partir do OpenAPI).
06Modelo de domínio
A conta de comerciante passa a ser a raiz de tudo. O comerciante fala com o usuário PhizChat de duas formas:
- Mensagem direta (padrão). O comerciante envia como ele mesmo, com o nome e a foto de Informações do comerciante. Não precisa ter canal.
- Pelo canal (opcional). Quem tem canais pode enviar por eles, por exemplo para separar marcas ou áreas.
As integrações pertencem ao comerciante e recebem permissão para usar uma das formas ou as duas.
flowchart LR M["Conta de comerciante
merchant_id"] --> I["Integrações
Blip · Salesforce · sistema próprio
client_id + secret cada"] I -- "POST /v1/{sender_id}/messages" --> API["PHIZ Business API"] API -- "Forma 1 · padrão
sender_id = merchant_id" --> D["Mensagem direta
do comerciante"] API -. "Forma 2 · opcional
sender_id = ID do canal" .-> C["Mensagem pelo
canal do comerciante"] D --> U(("Usuário PhizChat
telefone ou PHIZ ID")) C --> U API -- "webhook: respostas e status" --> I
As duas formas usam a mesma rota e o mesmo formato; muda apenas o sender_id. A forma 2 só existe para comerciantes que têm canal.
Fluxo ponta a ponta
Visão para o time técnico: por onde a mensagem passa, da empresa até o usuário, e por onde voltam as respostas e os status.
flowchart LR PP["PicPay"] --> B1["BSP
ex.: Blip"] AM["Ambar"] --> B2["BSP
ex.: Salesforce"] B1 --> API["PHIZ Business API
/gateway/business
Auth · Messages · Webhooks"] B2 --> API API -- "1–3 · token, envio e
validação do remetente" --> MA subgraph OP["Open Platform"] MA["Merchant Account"] MA -- "4a · sender_id = merchant_id
padrão" --> DM["Direct Message"] MA -. "4b · sender_id = ID do canal
opcional" .-> CH["Channel"] CH --> CM["Channel Message"] end DM -- "5 · entrega" --> APP["Phiz App"] CM -- "5 · entrega" --> APP APP --> U(("Usuário"))
Ida: da empresa até o usuário. A linha tracejada é o caminho opcional pelo canal.
Retorno: respostas e status
flowchart LR
U(("Usuário")) -- "responde, recebe, lê" --> APP["Phiz App"]
APP -- "6 · eventos" --> WH["PHIZ Business API
Webhooks"]
WH -- "webhook assinado" --> BSP["BSP da integração
responsável pelo remetente"]
BSP --> EMP["Empresa"]
Volta: respostas do usuário e status das mensagens chegam ao BSP pelo webhook.
- Integração. A empresa (por exemplo, PicPay ou Ambar) usa um BSP para o atendimento. Na Área do Desenvolvedor, o comerciante cria uma integração para esse BSP e entrega o
client_ide o secret. - Token. O BSP chama
/oauth/token. O Auth confere a integração na Merchant Account (secret, escopos e remetentes permitidos) e devolve um JWT de 1 h. - Envio. O BSP chama
POST /v1/{sender_id}/messages. A API valida token, escopo, remetente, política de opt-in eIdempotency-Key, responde202e coloca a mensagem na fila. - Roteamento. Com
sender_id = merchant_id, a mensagem segue como Direct Message (padrão). Com o ID de um canal, segue como Channel Message (opcional). - Entrega. O Phiz App exibe a mensagem ao usuário e baixa a mídia direto do link informado.
- Retorno. Respostas do usuário e status (
sent,delivered,read,failed) viram eventos que o serviço de Webhooks entrega, assinados, à integração responsável pelo remetente.
| Componente | Responsabilidade | Situação |
|---|---|---|
| BSP / sistema da empresa | Chama a API, hospeda as mídias por link e recebe o webhook. | Externo |
| PHIZ Business API · Auth | Emite e revoga JWT; valida client_id e secret. | Novo |
| PHIZ Business API · Messages | Valida e aceita envios (202), aplica idempotência e limites, enfileira e roteia por sender_id. | Novo |
| PHIZ Business API · Webhooks | Consome eventos do app, assina e entrega com retentativas; guarda histórico de entregas por 72 h. | Novo |
| Open Platform · Merchant Account | Guarda integrações, secrets (hash), escopos, remetentes permitidos e configuração de webhook. Hospeda a Área do Desenvolvedor. | Alterado: credenciais saem do canal e vão para o comerciante |
| Direct Message | Conversa direta entre comerciante e usuário no app. | Novo (ver Q3) |
| Channel / Channel Message | Envio pelo canal do comerciante. | Existente |
| Phiz App | Exibe mensagens, baixa mídia pelo link e gera eventos de entrega, leitura e resposta. | Alterado |
Equivalência com a Cloud API da Meta
| Meta (WhatsApp Cloud API) | PHIZ (esta proposta) |
|---|---|
| WhatsApp Business Account (WABA) | Conta de comerciante · merchant_id |
Número comercial · phone_number_id | Remetente · sender_id: o próprio comerciante (padrão, igual ao merchant_id) ou, opcionalmente, um canal (igual ao ID do canal) |
| App da Meta + token de System User | App de desenvolvedor + OAuth client credentials |
wa_id | phiz_id |
POST /{phone-number-id}/messages | POST /v1/{sender_id}/messages |
object: whatsapp_business_account | object: phiz_merchant_account |
X-Hub-Signature-256 | X-Phiz-Signature-256 + X-Phiz-Timestamp |
hub.mode / hub.verify_token / hub.challenge | Iguais |
07Princípios de design
- Mesmo formato da Cloud API. Corpos de envio, respostas e webhooks seguem a estrutura da Meta. Divergimos só onde há ganho claro de segurança (timestamp na assinatura) ou de clareza (
to_type). - Status HTTP, um único envelope de erro e
trace_idem toda resposta de erro. - O envio é assíncrono e responde
202 Accepted; o resultado chega pelo webhook. - Idempotência. O cabeçalho
Idempotency-Keyevita mensagens duplicadas quando o cliente repete uma requisição. - Menor privilégio. Cada integração tem escopos e uma lista de remetentes permitidos.
- Versionamento
/v1. Mudanças compatíveis (campos novos) entram de maneira padronizada; mudanças incompatíveis exigem/v2.
08Autenticação
Usamos o fluxo client credentials do OAuth 2.0 (RFC 6749 §4.4). O app troca client_id e client_secret por um access token de 1 hora e o envia em Authorization: Bearer em todas as chamadas. Não há refresh token: quando o token expira, o app pede outro.
/oauth/tokenEmite access token/oauth/revokeRevoga um token (RFC 7009)/v1/meMostra integração, comerciante, escopos e remetentes do token
sequenceDiagram
autonumber
participant S as Sistema externo
participant A as PHIZ Auth
participant API as PHIZ API
S->>A: POST /oauth/token (client_id, client_secret)
A-->>S: 200 access_token (1 h), escopos
S->>API: POST /v1/{sender_id}/messages · Bearer token
API-->>S: 202 Accepted
Perto de expirar, o sistema pede outro token. Não há refresh token.
Requisição e resposta
# Base URL of this API: current PHIZ gateway + /gateway/business prefix (illustrative host)
PHIZ_API_BASE="https://gateway.phiz.chat/gateway/business"
curl -X POST "$PHIZ_API_BASE/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d client_id=app_2Fk81PzQ \
-d client_secret="$PHIZ_CLIENT_SECRET" \
-d "scope=messages:send messages:read"{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBoei0yMDI2LTEwIn0…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "messages:send messages:read",
"merchant_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57"
}Os endpoints /oauth/* usam o formato de erro do OAuth (RFC 6749 §5.2), que as bibliotecas já entendem:
{
"error": "invalid_client",
"error_description": "Client authentication failed."
}Formato do token
Decisão: o token é um JWT assinado com ES256 e chave publicada em /.well-known/jwks.json. O gateway valida o token localmente, sem consulta ao banco a cada chamada. Revogações e secrets rotacionados entram numa lista de negação de curta duração (até a expiração do token). Para o integrador o token é opaco; o conteúdo abaixo é informativo.
{
"iss": "https://gateway.phiz.chat/gateway/business",
"sub": "app_2Fk81PzQ",
"aud": "phiz-business-api",
"mid": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"scope": "messages:send messages:read",
"snd": [
"7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"1231"
],
"env": "live",
"iat": 1760527800,
"exp": 1760531400,
"jti": "tok_01JA9VQ4N2"
}Escopos
| Escopo | Permite |
|---|---|
messages:send | Enviar mensagens e marcar como lidas |
messages:read | Consultar status de mensagens |
webhooks:manage | Configurar, testar e reenviar o webhook do próprio app |
account:read | Ler dados da conta de comerciante e dos remetentes |
templates:manage | Reservado para a Marketing API |
Secrets e rotação
- O secret tem prefixo de ambiente (
phzsk_live_,phzsk_test_), o que ajuda scanners de vazamento a reconhecê-lo. - É exibido uma única vez, na criação ou rotação, e guardado apenas como hash (Argon2id).
- Cada app pode ter até dois secrets ativos. Ao gerar um novo, o anterior continua válido pelo período escolhido (padrão 24 h, de 0 a 7 dias), permitindo trocar sem queda.
- A tela mostra o último uso de cada secret, para saber se o antigo ainda está em uso antes de revogá-lo.
- Opcional: lista de IPs permitidos por app.
Atual × proposto
| Aspecto | Hoje | Proposta |
|---|---|---|
| Dono da credencial | Canal | Conta de comerciante (via app) |
| Fluxo | user_credentials (próprio) | client_credentials (RFC 6749) |
| Campos obrigatórios extras | description | Nenhum |
| Validade | ~2 h + refresh token ~90 dias | 1 h, sem refresh token |
| Escopos | channel | Por capacidade + remetentes permitidos |
| Rotação | Redefinir invalida na hora | Dois secrets ativos, com sobreposição |
| Erros | HTTP 200 + code no corpo | Status HTTP real + envelope padrão |
09Envio de mensagens
/v1/{sender_id}/messagesEnvia mensagem ou marca como lida/v1/messages/{message_id}Último status conhecido (contingência)/v1/merchants/{merchant_id}/sendersRemetentes (comerciante e canais) que a integração pode usarRemetente: o comerciante ou um canal
O sender_id no caminho define em nome de quem a mensagem sai:
- O próprio comerciante (padrão). Use o
merchant_id. A mensagem aparece para o usuário numa conversa Business Account, direta com a empresa, e não depende de o comerciante ter canal. - Um canal (opcional). Use o ID do canal, por exemplo
1231. Útil para quem separa marcas ou áreas em canais diferentes. A mensagem aparece na conversa Channel Service do canal, a mesma usada pelo Channel Bot.
Os dois funcionam do mesmo jeito na API: mesmos campos, mesmas respostas e mesmos webhooks. Nos webhooks, metadata.sender_type indica qual foi usado e metadata.conversation_type traz o tipo de conversa (business_account ou channel_service).
Destinatário: telefone ou PHIZ ID
O campo to recebe o destinatário e to_type diz como interpretá-lo:
phone(padrão): telefone em E.164, por exemplo+5511987654321. A PHIZ resolve o número para a conta do usuário.phiz_id: o PHIZ ID do usuário, por exemplo839201447. É o identificador que chega nos webhooks, então respostas podem usá-lo diretamente.
Se o telefone não pertencer a uma conta PHIZ, ou o usuário tiver bloqueado o remetente, a API ainda responde 202 e o webhook informa failed com erro 2104. Assim a API não serve para descobrir quais números têm conta na PHIZ.
Exemplo: texto para telefone
# sender_id = merchant_id: direct message from the merchant, no channel needed
curl -X POST "$PHIZ_API_BASE/v1/7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57/messages" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-48213-envio" \
-d '{
"messaging_product": "phiz",
"to": "+5511987654321",
"to_type": "phone",
"type": "text",
"text": { "body": "Olá, Maria! Seu pedido #48213 foi enviado." },
"biz_opaque_callback_data": "pedido=48213"
}'{
"messaging_product": "phiz",
"contacts": [
{
"input": "+5511987654321",
"phiz_id": null
}
],
"messages": [
{
"id": "pmid.01JA9VZ6T8R3",
"message_status": "accepted"
}
]
}contacts[].phiz_id só vem preenchido se o usuário já conversou com esse remetente. messages[].id é a chave para relacionar os webhooks de status.
Outros exemplos
O mesmo envio, saindo por um canal
# sender_id = 1231: same request, sent through the merchant's channel
curl -X POST "$PHIZ_API_BASE/v1/1231/messages" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "+5511987654321", "type": "text", "text": { "body": "Novidades da semana na Loja Exemplo - Ofertas" } }'Texto para PHIZ ID
{
"to": "839201447",
"to_type": "phiz_id",
"type": "text",
"text": {
"body": "Seu cupom chegou: PHIZ10"
}
}Áudio por link
{
"to": "+5511987654321",
"type": "audio",
"audio": {
"link": "https://cdn.lojaexemplo.com.br/audios/aviso-entrega-48213.mp3"
}
}Vídeo por link, para PHIZ ID
{
"to": "839201447",
"to_type": "phiz_id",
"type": "video",
"video": {
"link": "https://cdn.lojaexemplo.com.br/videos/montagem-48213.mp4",
"caption": "Como montar seu produto"
}
}Documento com nome de arquivo
{
"to": "+5511987654321",
"type": "document",
"document": {
"link": "https://cdn.lojaexemplo.com.br/nf/48213.pdf",
"filename": "nota-fiscal-48213.pdf",
"caption": "Nota fiscal do pedido #48213"
}
}Botões de resposta
{
"to": "+5511987654321",
"type": "interactive",
"interactive": {
"type": "button",
"body": {
"text": "Confirma a entrega amanhã entre 9h e 12h?"
},
"action": {
"buttons": [
{
"type": "reply",
"reply": {
"id": "confirmar",
"title": "Confirmar"
}
},
{
"type": "reply",
"reply": {
"id": "reagendar",
"title": "Reagendar"
}
}
]
}
}
}Marcar mensagem recebida como lida
{
"messaging_product": "phiz",
"status": "read",
"message_id": "pmid.IN_01JA9W2C"
}Tipos suportados
| type | Conteúdo | Fase |
|---|---|---|
text | Até 4.096 caracteres; preview_url opcional | MVP |
image | Por link HTTPS; legenda opcional | MVP |
audio | Por link HTTPS; sem legenda | MVP |
video | Por link HTTPS; legenda opcional | MVP |
document | Por link HTTPS; legenda e nome do arquivo opcionais | MVP |
Formatos e limites de mídia
A mídia é enviada sempre por link: uma URL HTTPS pública, hospedada pelo comerciante ou pelo parceiro. A API de mensagens não processa nem armazena mídia; ela apenas repassa o link para o app do usuário, que baixa o arquivo direto dessa URL. Por isso o link precisa continuar acessível depois do envio, e o arquivo já deve estar no formato e no tamanho aceitos pelo app.
| Tipo | Formatos aceitos | Tamanho máximo |
|---|---|---|
image | JPEG, PNG, WebP | 5 MB |
audio | AAC, MP3, M4A, OGG (Opus), AMR | 16 MB |
video | MP4 (H.264 + AAC), 3GP | 16 MB |
document | PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, TXT, CSV | 100 MB |
Formatos e tamanhos que o app PHIZ deve aceitar, inspirados nos limites da Cloud API. A conversão e a compressão ficam a cargo de quem hospeda o arquivo. Precisam ser confirmados com o time responsável pela mídia no app (ver Q8).
Política de envio (consentimento)
Proposta inspirada no WhatsApp (ver seção 03), com os fluxos de opt-in detalhados na seção 04:
- Iniciar conversa exige opt-in do usuário para aquele comerciante (
merchant_id) e categoria: alertas da conta, ofertas ou códigos. Responder em até 24 h depois de uma mensagem do usuário não exige opt-in prévio. - O opt-in pode ser coletado fora do PHIZ (app, site ou loja do comerciante) ou dentro do app PHIZ.
- Opt-out aplicado pela plataforma: se o usuário bloqueia o comerciante ou para as ofertas, os envios daquela categoria falham com erro, sem depender do comerciante.
- Registro obrigatório: o comerciante guarda a prova de cada consentimento (usuário,
merchant_id, categoria, origem, data, texto exibido e revogação). Pela LGPD, cabe a quem trata os dados provar o consentimento e respeitar a revogação.
A Meta tem a política de opt-in mas não a valida: confia no comerciante e reage depois, por denúncias e nota de qualidade. Precisamos escolher entre A, o mesmo modelo (política mais registro no comerciante), e B, um registro de consentimento no PHIZ, verificado antes de cada envio. Ver Q1.
Idempotência
Envie Idempotency-Key em todo envio. A mesma chave em até 24 h devolve a resposta original sem reenviar. A mesma chave com corpo diferente devolve 409. Isso torna seguros os retries após timeout ou erro 5xx.
10Webhooks
Cada app tem uma assinatura de webhook com URL, verify_token e lista de eventos. A configuração é feita na Área do Desenvolvedor ou pela API.
/v1/apps/{app_id}/webhookDefine URL, verify_token e eventos/v1/apps/{app_id}/webhookConfiguração e saúde/v1/apps/{app_id}/webhook/testDispara evento de teste assinado/v1/apps/{app_id}/webhook/deliveriesHistórico de entregas (72 h)/v1/apps/{app_id}/webhook/deliveries/{id}/replayReenvia uma entregacurl -X PUT "$PHIZ_API_BASE/v1/apps/app_2Fk81PzQ/webhook" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://integracao.lojaexemplo.com.br/phiz/webhook",
"verify_token": "um-texto-que-so-voce-conhece",
"fields": ["messages", "message_status"]
}'Eventos
| field | Quando |
|---|---|
messages | O usuário enviou mensagem, clicou num botão ou reagiu |
message_status | Uma mensagem enviada mudou para sent, delivered, read ou failed |
sender_update | Mudou o status, a qualidade ou o limite de um remetente (comerciante ou canal) |
account_update | Mudança na conta de comerciante (verificação, restrição) |
Verificação da URL
Antes de ativar, a PHIZ faz um GET na URL com hub.mode=subscribe, hub.verify_token e hub.challenge. O servidor confere o token e responde 200 com o valor de hub.challenge. É o mesmo handshake da Meta.
Assinatura
Todo POST traz os cabeçalhos abaixo. A assinatura é um HMAC-SHA256 de {X-Phiz-Timestamp}.{corpo bruto} com o webhook secret do app, separado do client secret. Rejeite eventos com timestamp mais de 5 minutos fora do relógio. Durante a rotação do webhook secret, o cabeçalho traz duas assinaturas separadas por vírgula.
POST /phiz/webhook HTTP/1.1
Host: integracao.lojaexemplo.com.br
Content-Type: application/json
User-Agent: PHIZ-Webhooks/1.0
X-Phiz-Delivery-Id: dlv_01JA9W2D7K
X-Phiz-Timestamp: 1760528527
X-Phiz-Signature-256: sha256=6f1c0e…// Express: verification handshake + signed events
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.raw({ type: "application/json" })); // keep the raw body for the HMAC
app.get("/phiz/webhook", (req, res) => {
const ok = req.query["hub.mode"] === "subscribe" &&
req.query["hub.verify_token"] === process.env.PHIZ_VERIFY_TOKEN;
return ok ? res.status(200).send(req.query["hub.challenge"]) : res.sendStatus(403);
});
app.post("/phiz/webhook", (req, res) => {
const ts = Number(req.get("X-Phiz-Timestamp"));
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.PHIZ_WEBHOOK_SECRET)
.update(`${ts}.${req.body}`)
.digest("hex");
const received = (req.get("X-Phiz-Signature-256") || "").split(",");
const valid = received.some(sig => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
if (!valid) return res.sendStatus(401);
queue.push({ deliveryId: req.get("X-Phiz-Delivery-Id"), event: JSON.parse(req.body) });
res.sendStatus(200); // answer fast, process asynchronously
});# Flask: signature check
import hmac, hashlib, time, os
from flask import request, abort
def verify_phiz_signature() -> None:
ts = int(request.headers["X-Phiz-Timestamp"])
if abs(time.time() - ts) > 300:
abort(400)
msg = f"{ts}.".encode() + request.get_data() # raw bytes, before JSON parsing
expected = "sha256=" + hmac.new(os.environ["PHIZ_WEBHOOK_SECRET"].encode(), msg, hashlib.sha256).hexdigest()
received = request.headers.get("X-Phiz-Signature-256", "").split(",")
if not any(hmac.compare_digest(sig, expected) for sig in received):
abort(401)Mensagem recebida
{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"contacts": [
{
"profile": {
"name": "Maria Souza"
},
"phiz_id": "839201447",
"phone": "+5511987654321"
}
],
"messages": [
{
"from": "839201447",
"id": "pmid.IN_01JA9W2C",
"timestamp": "1760528527",
"type": "text",
"text": {
"body": "Quero reagendar a entrega"
},
"context": {
"id": "pmid.01JA9VZ6T8R3"
}
}
]
}
}
]
}
]
}Mensagem recebida com mídia
Quando o usuário envia imagem, áudio, vídeo ou documento, o evento traz um url temporário que aponta para o arquivo no armazenamento atual do chat. A API de mensagens não faz cópia nem guarda a mídia; baixe o arquivo enquanto o link estiver válido. Abaixo, o item messages[] de uma mensagem de voz:
{
"from": "839201447",
"id": "pmid.IN_01JA9W7R",
"timestamp": "1760528731",
"type": "audio",
"audio": {
"url": "https://media.phiz.chat/in/9xT2…?expires=1760615131&sig=…",
"mime_type": "audio/ogg; codecs=opus",
"sha256": "3f9a…",
"voice": true
}
}Status de mensagem enviada
{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "message_status",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"statuses": [
{
"id": "pmid.01JA9VZ6T8R3",
"status": "delivered",
"timestamp": "1760528410",
"recipient_id": "839201447",
"biz_opaque_callback_data": "pedido=48213",
"conversation": {
"id": "conv_01JA9W",
"origin": {
"type": "service"
},
"expires_at": "1760614810"
}
}
]
}
}
]
}
]
}{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "message_status",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"statuses": [
{
"id": "pmid.01JA9X0QW1",
"status": "failed",
"timestamp": "1760528600",
"recipient_id": "+5511900000000",
"errors": [
{
"code": 2104,
"title": "recipient_not_reachable",
"message": "The phone number is not linked to a PHIZ account or blocked this sender.",
"href": "https://developers.phiz.chat/docs/errors#2104"
}
]
}
]
}
}
]
}
]
}stateDiagram-v2 direction LR [*] --> accepted: 202 da API accepted --> sent: entregue ao servidor PHIZ sent --> delivered: chegou ao aparelho delivered --> read: usuário abriu accepted --> failed sent --> failed read --> [*] failed --> [*]
Ciclo de vida do status. accepted só aparece na resposta da API e no GET /v1/messages/{id}; os demais chegam por webhook.
Garantias de entrega
- Pelo menos uma vez. O mesmo evento pode chegar mais de uma vez; use
X-Phiz-Delivery-Idpara descartar duplicados. - Sem ordem garantida. Um
readpode chegar antes dodelivered; ordene portimestampe nunca regrida status. - Responda em até 10 s com 2xx e processe depois. Qualquer outra resposta, ou timeout, conta como falha.
- Retentativas com backoff exponencial (10 s, 30 s, 1 min, 5 min, 15 min, 1 h e depois a cada hora) por até 24 h. Depois disso o evento fica disponível para reenvio manual por 72 h.
- Se a URL falhar continuamente por 24 h, o webhook passa para
failinge os administradores do comerciante recebem um aviso.
11Erros, limites e versões
Fora de /oauth/*, todos os erros usam um envelope único:
{
"error": {
"code": 1003,
"type": "invalid_parameter",
"message": "Field 'to' must be E.164 when to_type is 'phone'.",
"details": [
{
"field": "to",
"issue": "format"
}
],
"trace_id": "trc_9f2c41ab"
}
}| code | HTTP | type | Significado |
|---|---|---|---|
| 1001 | 400 | invalid_request | JSON malformado ou campo obrigatório ausente |
| 1003 | 400 | invalid_parameter | Valor inválido (ver details) |
| 1101 | 401 | token_expired | Token expirado; peça outro |
| 1102 | 401 | invalid_token | Token inválido ou revogado |
| 1201 | 403 | insufficient_scope | O token não tem o escopo necessário |
| 1202 | 403 | sender_not_allowed | A integração não tem permissão para esse remetente |
| 1301 | 404 | not_found | Recurso inexistente ou de outro comerciante |
| 1401 | 409 | idempotency_conflict | Mesma Idempotency-Key com corpo diferente |
| 1501 | 429 | rate_limited | Limite de requisições; respeite Retry-After |
| 2104 | webhook | recipient_not_reachable | Telefone sem conta PHIZ ou usuário bloqueou o remetente |
| 2201 | 422 | policy_violation | Envio não permitido pela política de opt-in |
| 2202 | 422 | unsupported_type | Tipo de mensagem não habilitado para o remetente |
| 2301 | webhook | media_unavailable | O app não conseguiu baixar a mídia do link (URL fora do ar, expirada ou formato não aceito) |
| 2401 | 403 | sender_disabled | Remetente desativado ou restrito |
| 2501 | 429 | messaging_limit_reached | Limite diário de destinatários do remetente atingido |
| 5000 | 500 | internal_error | Erro interno; repita com a mesma Idempotency-Key |
Limites (proposta inicial)
| Recurso | Limite |
|---|---|
/oauth/token | 60 req/min por app |
| Envio de mensagens | 80 msg/s por remetente (ajustável por contrato) |
| Demais endpoints | 600 req/min por app |
| Destinatários únicos de mensagens iniciadas pela empresa | Faixas por remetente: 1 mil → 10 mil → 100 mil → ilimitado em 24 h, conforme qualidade (Fase 2) |
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 80
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1760528529Ambientes
- Produção:
https://{gateway}/gateway/business - Sandbox:
https://{gateway-de-teste}/gateway/business, no ambiente de testes usado hoje (Openapi-test), com um remetente de teste por comerciante e até 5 destinatários cadastrados (como o número de teste da Meta). Secretsphzsk_test_só funcionam aqui.
Prefixo de rotas
A nova API roda no mesmo gateway da API atual, separada por prefixo:
/gateway/openapi/…: API atual, mantida durante a migração./gateway/business/…: esta API (autenticação, mensagens e webhooks).
Com prefixos distintos, cada contexto tem roteamento, autenticação, limites e logs próprios, e o legado pode ser desligado sem afetar a nova API. Todos os caminhos deste documento, como /oauth/token e /v1/{sender_id}/messages, são relativos a https://{gateway}/gateway/business. Nos exemplos, esse endereço aparece como $PHIZ_API_BASE.
12Área do Desenvolvedor no Open Platform
A configuração de desenvolvedor sai de Canal › Configuração de Desenvolvimento e vira um item de menu do comerciante, Desenvolvedor, ao lado de Configurações. Ela reaproveita o ID do Comerciante, as funções de Gerenciamento da equipe e o Registro de operações que já existem.
Mockups da nova área, com valores de exemplo. Clique em uma imagem para abrir em tamanho real.
Telas
Visão geral
/developer- Mensagens enviadas, entregues e com falha nas últimas 24 h
- Saúde dos webhooks
- Alertas: secret antigo em uso, webhook falhando
Apps
/developer/apps- Criar app: nome, tipo de integração (própria, Blip, Salesforce, outra), ambiente
- Escopos e remetentes permitidos
- Client ID, secrets com rotação e último uso
- Lista de IPs permitidos (opcional)
- Desativar ou excluir app
Webhook do app
/developer/apps/:id/webhook- URL, verify token e eventos
- Webhook secret com rotação
- Verificar e enviar teste
- Entregas das últimas 72 h com status HTTP, tentativas, latência e reenviar
Remetentes
/developer/senders- O próprio comerciante, sempre presente, e os canais, se houver
- Cada um com
sender_id, status, limite e qualidade - Escolhe a integração que recebe as conversas de cada remetente
- Para canais, liga o modo “Phiz Business API”
Logs de API
/developer/logs- Requisições dos últimos 7 dias: método, rota, status, latência,
trace_id - Filtro por app e por código de erro
Sandbox
/developer/sandbox- Remetente de teste
- Cadastro de até 5 destinatários de teste
- Credenciais
phzsk_test_
Permissões
Novas permissões entram na tela Gestão de Funções já existente. O Superadministrador recebe todas.
| Permissão | Permite |
|---|---|
developer.view | Ver integrações, remetentes, logs e entregas (sem secrets) |
developer.apps.manage | Criar, editar e desativar integrações; definir escopos e remetentes |
developer.secrets.manage | Gerar, rotacionar e revogar secrets |
developer.webhooks.manage | Configurar webhook, testar e reenviar entregas |
Criar, rotacionar ou revogar um secret, mudar escopos e alterar o webhook gera registro em Registro de operações e um e-mail para os administradores.
Remetente com mais de uma integração
Para evitar que dois sistemas respondam ao mesmo usuário, cada remetente (o comerciante ou um canal) tem uma integração responsável pelas conversas, escolhida na tela Remetentes. Só essa integração recebe o evento messages daquele remetente. Qualquer integração com permissão pode enviar por ele e recebe o message_status das mensagens que enviou. Um protocolo de passagem de conversa entre integrações (como o handover do Messenger) fica para depois.
Migração das credenciais de canal
- Criação automática. Para cada canal com AppID, criamos um app “Legado – nome do canal” no comerciante, com o mesmo AppID como
client_id, o mesmo secret (já armazenado) e permissão só para aquele canal. Nenhuma integração para. - Convivência.
/gateway/openapi/authe/token/refreshcontinuam funcionando. A página do canal mostra um aviso apontando para a nova área. - Somente leitura. Após 3 meses, a página do canal deixa de permitir redefinir o secret; a gestão passa a ser só na nova área.
- Desligamento. Após 6 meses, e com o uso medido perto de zero, os endpoints antigos respondem
410 Gone.
13Integração com parceiros
No MVP, o comerciante cria um app na Área do Desenvolvedor e cola client_id e secret na Blip, no Salesforce ou no próprio sistema. As URLs de webhook vêm do parceiro. Como os contratos seguem a Cloud API, parceiros que já têm conector de WhatsApp reaproveitam a maior parte dele.
Numa fase seguinte, criamos o programa de parceiros de tecnologia, equivalente ao Embedded Signup da Meta:
- O parceiro registra um app de plataforma na PHIZ.
- O comerciante clica em “Conectar com a PHIZ” dentro do parceiro e autoriza escopos e remetentes numa tela de consentimento (OAuth 2.0 authorization code + PKCE).
- O parceiro recebe tokens limitados àquele comerciante e um único webhook para todos os seus clientes. O campo
entry[].iddo envelope identifica o comerciante; por isso o envelope já nasce com essa estrutura.
14Caminho para a Marketing API
Esta RFC reserva o espaço que a Marketing API vai ocupar, sem implementá-la:
type: templateno envio e o escopotemplates:managejá existem no contrato.- Templates com aprovação:
/v1/merchants/{merchant_id}/message_templatese evento de webhooktemplate_status. - Categorias de conversa (
conversation.origin.type:service,utility,marketing), base para relatórios e preço. - Gestão de opt-in e opt-out por remetente, campanhas para segmentos e métricas em
/v1/merchants/{merchant_id}/analytics. - Faixas de limite de envio e nota de qualidade por remetente, já presentes no recurso
Sender.
15Plano de entrega
Autenticar e enviar
/oauth/token,/oauth/revoke,/v1/me- Envio de
text,image,audio,videoedocumentpara telefone e PHIZ ID - Webhooks
messagesemessage_statuscom assinatura - Área do Desenvolvedor: apps, secrets, webhook
- Remetente padrão (o próprio comerciante) para todos; canais como remetentes opcionais
- Migração automática das credenciais de canal
Datas serão definidas após a revisão desta RFC com o time da China.
16Questões em aberto
- Política de opt-in. A seção 09 propõe a política (opt-in por comerciante e categoria, opt-out aplicado pela plataforma, registro no comerciante). Falta decidir: modelo A (como a Meta, sem validação) ou B (registro de consentimento no PHIZ, por exemplo
/v1/merchants/{merchant_id}/consents, verificado no envio); quais categorias existem; se o app PHIZ mostra ao usuário o que ele autorizou; e como o usuário bloqueia ou para ofertas de um Business Account (ver Q3). Validar os pontos de LGPD com o jurídico. Vale para Business Account e Channel Service. - IDs de remetente. Usar diretamente o
merchant_ide o ID do canal comosender_id, ou criar IDs opacos novos? Usar os existentes simplifica a migração, mas expõe o ID sequencial do canal. - Tipo de conversa Business Account. Confirmar o caminho Contas públicas › Service › Business Account e a definição: conversa de serviço de mão dupla para empresas, comerciantes, governos e instituições, com mensagens do usuário permitidas e Menu configurável, e mensagens ativas sujeitas às regras de autorização e envio. Esse tipo já existe no app ou precisa ser construído? Como o usuário bloqueia ou silencia um Business Account?
- Tipos de conversa vigentes. Revisar a hierarquia e a tabela de permissões (v1.2): quais tipos existem hoje, quais regras atuais são diferentes, quais tipos não existem e quais estão faltando.
- Telefones. Todos os usuários PHIZ têm telefone verificado? Como tratar números de outros países e trocas de número?
- Uma integração responsável por remetente é suficiente, ou precisamos de passagem de conversa entre apps (bot → atendente humano) já na Fase 2?
- Business Message API existente. O modo “Phiz Business API” do Robô de IA já tem endpoints de envio e callback e parece corresponder ao Channel Service com Channel Bot. Esta RFC substitui, estende ou encapsula essa API?
- Mídia por link. O app PHIZ reproduz todos os formatos da tabela de mídia e quais tamanhos aceita? O app baixa direto da URL do comerciante (o que expõe o IP do usuário a esse servidor) ou por um proxy da PHIZ que não armazena? Devemos verificar o link antes de aceitar o envio (por exemplo, checar tipo e tamanho sem baixar o arquivo)? Por quanto tempo o
urldas mídias recebidas fica válido?
17Referência OpenAPI
A especificação OpenAPI 3.1 abaixo cobre todos os endpoints e webhooks desta RFC, com descrições neste idioma. O botão “Try it out” está desligado porque a API ainda não existe. Use “Copiar OpenAPI” para levar o YAML para Apifox, Postman ou um gerador de SDK.
PHIZ Business Messaging API
Merchant-level authentication, sending messages to a phone number or PHIZ ID, and webhooks for messages and statuses. Inspired by Meta's Cloud API and designed as the foundation of the future Marketing API.
01Summary
This document proposes a public API that lets external systems (Blip, Salesforce, CRMs or the merchant's own systems) talk to PHIZ users on behalf of a merchant. It has four parts:
- OAuth 2.0 authentication (client credentials) with credentials issued to the merchant account instead of a channel.
- Sending messages to a phone number (E.164) or a PHIZ ID directly on behalf of the merchant. Using a channel as the sender is optional.
- Signed webhooks that deliver messages received from users and every status change of sent messages.
- A merchant-level Developer area in the Open Platform where the merchant registers each external system that will use the API (for example Blip or Salesforce) as an integration. Each integration has its own
client_idand secret, webhook and delivery history. Integrations are unrelated to Miniapps.
Message and webhook contracts reproduce the structure of Meta's Cloud API, renaming only domain terms. Anyone already integrated with the WhatsApp Business Platform can adapt by changing URLs, credentials and a few field names.
02Context: how it works today
Based on the current documentation (Apifox, Authentication module) and the Open Platform (open.phiz.chat).
Current authentication
The token is obtained at POST /gateway/openapi/auth with app_id, app_secret, description (required) and grant_type: "user_credentials". The response carries an access token valid for about 2 hours and a refresh token valid for about 90 days, renewed at POST /gateway/openapi/token/refresh.
POST /gateway/openapi/auth
Content-Type: application/json
{
"app_id": "69c37cb1821121af34201f48",
"app_secret": "94B18B920D19463C7264BD0BDE30C31…",
"description": "push news",
"grant_type": "user_credentials",
"device_fingerprint": ""
}
200 OK
{
"code": 200,
"msg": "success",
"data": {
"scope": "channel",
"status": "active",
"access_token": "at_1770343926041_1Y3iz6XN…",
"token_type": "Bearer",
"expires_in": 7199,
"refresh_token": "rt_1770343926041_mxc7RV9…",
"access_token_expires_at": "2026-02-06T04:…",
"refresh_token_expires_at": "2026-05-07T02:…",
"created_at": "2026-02-05T10:15:23.672+00:00"
},
"success": true
}Where credentials live
In the Open Platform, every channel has a Channel › Development Settings page with an AppID and an AppSecret. The secret is never displayed and can only be reset. Under AI Robot, the channel also offers the “Phiz Business API” reply mode, which routes the channel's conversations to the Business Message API for sending and webhook callbacks.
At merchant level there is already a Merchant ID (UUID) and Team management with roles, members and an operation log. These three pieces are the foundation of the new area.
What this RFC changes
Each row shows how things work today, why that is a problem, what this proposal puts in its place, and a concrete use case.
| Today | Why it is a problem | With this RFC | Use case |
|---|---|---|---|
| To reach a user through the API, the merchant needs a channel. | Merchants without a channel, or that want to speak as the business itself, cannot send a direct message to the user. | The merchant sends a direct message as itself. Using a channel becomes optional. | A pharmacy with no channel tells its customer Maria, by her phone number, that her order is ready for pickup. The message arrives in a direct conversation with the pharmacy. |
| Each channel has its own AppID and secret. | A merchant with 5 channels manages 5 credentials, and Blip needs one token per channel. | One credential per integration, owned by the merchant account, with access to direct sending and the chosen channels. | Loja Exemplo has 3 channels and uses Blip. Today it registers 3 AppIDs in Blip; with this RFC it creates one “Blip” integration and ticks direct sending and the 3 channels. |
| “Reset” invalidates the secret immediately. | The integration stays down until someone updates the secret in the external system. | Two valid secrets at the same time: the new one goes live and the old one keeps working for a period (default 24 h). | The Salesforce secret showed up in a log. The team generates a new one, updates Salesforce in the afternoon and revokes the old one at the end of the day, without losing a single message. |
Login uses grant_type: user_credentials and requires a description field. | Tools that already speak OAuth, such as Salesforce, cannot connect without custom code. | Standard OAuth 2.0 (client_credentials), which those tools support out of the box. | The Salesforce admin connects PHIZ in Named Credentials by filling in only the token URL, client_id and secret, with no developer involved. |
| There is a refresh token valid for about 90 days. | It is one more long-lived secret to store, with no benefit in a server-to-server integration. | 1-hour token with no refresh token; when it expires, the system requests a new one. | Blip renews the token on its own every hour with the same client_id and secret. There is no refresh token that can expire over a weekend and take customer service down. |
| There is no screen for webhooks, logs or multiple integrations. | The merchant cannot set up the webhook or find out alone why an integration stopped. | A Developer area with separate integrations, webhook, delivery history and logs. | Customer replies stop arriving in Blip. The merchant opens the delivery history, sees the webhook has returned HTTP 500 since 10 a.m. and, after the fix, replays the events. |
03Reference: the WhatsApp experience
This RFC brings the WhatsApp Business Platform model to PHIZ. So that everyone (Brazil and China) shares the same picture of what we are copying, this section walks through a Brazilian user's journey on WhatsApp: how they create an account, how they authorize a business, how a message arrives and what they can do with it. Example: merchant PicPay, user Ana Souza, in São Paulo.
They are illustrations, not the official WhatsApp UI. Text is shown in English; in the real app the user sees Portuguese. The codes (A2, O1, C3…) follow the Design canvas this section comes from. Click an image to open it at full size.
Message path
A business does not talk to WhatsApp directly: its system (CRM, customer service) calls a BSP (Blip, Zenvia, Twilio, Infobip), which calls Meta's Cloud API, which delivers to the phone. Replies, taps and statuses come back by webhook along the same path. In PHIZ, the PHIZ Business Messaging API takes the place of the Cloud API and the merchant account (merchant_id) takes the place of the WABA (see the mapping in section 06).
User sign-up
On WhatsApp, the account is the phone number, verified by SMS. There are no friend requests: anyone with the number can write. Businesses and family share one inbox, which is why business messaging is tightly controlled. Since 2026 WhatsApp offers usernames and business-scoped user IDs (BSUID) instead of the phone number, which lines up with the use of PHIZ ID in this RFC.
User authorization (opt-in)
Before a business starts a conversation, the user must have authorized that business. There are four common ways: in the business's app (O1), at sign-up or checkout (O2), when the user writes first, which covers only that service conversation (O3), and by answering “yes” inside the chat (O4). Good practice is to split by purpose: authorizing account alerts does not authorize offers.
Meta has the policy but does not verify the opt-in. There is no field or endpoint to prove authorization; Meta trusts the business and only reacts afterwards, through blocks, reports and the quality rating. Opt-out, on the other hand, is enforced by the platform: blocking, “stop offers” and the marketing cap make sends fail. Even so, under Brazil's LGPD the business must prove consent and honor withdrawal, so the record (who, merchant, category, source, date, wording shown, revocation) is required in practice.
What the user receives
Business-initiated messages use approved templates in four categories: utility (payments, bills), marketing, authentication (codes) and service. Each category depends on the matching opt-in.
What the user can do
The user replies (text, buttons, list, form), checks the business profile or withdraws permission. Each action does, or does not, create an event for the business.
| User action | On WhatsApp | On PHIZ |
|---|---|---|
| Replies with text or media | messages webhook; opens the 24 h window | message event (section 10) |
| Taps a button or list row | button_reply / list_reply | message event with the chosen id |
| Stops offers | Marketing sends fail | Proposal: the platform blocks the category for that merchant_id |
| Blocks or reports | All sends fail; quality rating drops | Proposal: send error plus a quality signal (see Q3) |
| Mutes or archives | Invisible to the business | Same |
What we take to PHIZ
- The business identity throughout the flow is the Merchant ID (
merchant_id): it receives the opt-in, the block and the quality rating. There is no separate developer ID; integrations and apps only act on behalf of the merchant. - Consent per merchant and per category, with opt-out enforced by the platform (see Sending policy, section 09).
- User signals (block, report, stop offers) feeding the merchant's sending limits.
- In-chat forms (Flows) as a reference for mini apps.
04Opt-in in PhizChat: in the app and at the business
Section 03 showed that on WhatsApp opt-in almost always happens outside the app and Meta does not verify it. This section proposes how a user authorizes a business account in PhizChat, through two paths that end in the same opt-out controls:
- Flow P — inside PhizChat: the user accepts the business account in the app itself. The platform keeps the proof.
- Flow Q — at the business, outside PhizChat: the user authorized in the merchant's app, website or store, and the merchant starts the conversation.
They are illustrative proposals, with PicPay as the example merchant. The codes (P0…P8, Q0…Q5) follow the Design canvas. Click an image to open it at full size.
Flow P · Opt-in inside PhizChat
The user reaches the business account through an SMS or e-mail link, a QR code, the merchant's app or website, or in-app search. Without Phiz installed, they see an H5 page with only the profile and a download button; acceptance never happens outside the app. After installing and logging in, a deferred deep link takes them to the same chat, which opens locked until Accept. On Accept, PhizChat records the consent (user, merchant_id, date, entry point), sends the welcome template if there is one, and unlocks the chat.
After Accept, the profile shows the consent status and date. The user can revoke (the chat goes back to the locked state and can be authorized again) or block (reuses today's Block; the API still accepts calls but returns the blocked status).
Flow Q · Opt-in collected by the business, outside PhizChat
Most Brazilian companies already collect consent in their own channels. In flow Q, the user turns on “messages on PhizChat” in the PicPay app; PicPay keeps the proof and sends a template through the API. The API checks that the recipient is a Phiz user and has not blocked or revoked; in model B it also checks for a registered consent for that category. In the app, the first message arrives with a consent source card (what was authorized, where and when), and the user can keep receiving, stop or report.
P vs Q
| P · opt-in in PhizChat | Q · opt-in at the business | |
|---|---|---|
| Who collects | PhizChat (Accept) | The merchant (app, website, store) |
| Proof lives in | PhizChat | Merchant CRM (model A) · also PHIZ (model B) |
| First chat | Locked until Accept | Opens with the message and the consent source |
| Opt-out | Same for both: revoke, block, report — enforced by PhizChat | |
Open decisions
- Model A vs B in flow Q (see Q1): policy only, like Meta, or a consent registry in PHIZ checked on every send. The full consent source card (Q3) and per-category status (Q4) depend on model B.
- Delivery before Accept in flow Q: the proposal delivers the first message with the consent source card; the alternative is to lock the chat until Accept, as in flow P.
- Consent categories (account alerts, offers, codes) and what the app shows the user.
- Technical names —
/v1/merchants/{merchant_id}/consents, theconsent_updatewebhook and the error codes — are placeholders for discussion.
05Goals and non-goals
Goals
- Credentials tied to the merchant account instead of each channel. The merchant creates one integration per external system (for example, one for Blip and another for Salesforce). Each integration gets its own
client_idand secret, and the merchant decides what it can do (send messages, check statuses, set up the webhook) and which senders it can speak through: the merchant itself and, if the merchant wants, its channels. - Standard OAuth 2.0 (client credentials), compatible with common libraries and platforms.
- Send messages to a phone number or PHIZ ID and receive replies and statuses through a signed webhook.
- Structural compatibility with Meta's Cloud API so existing integrations migrate with little code.
- Publish the API specification in OpenAPI 3.1 format (the Swagger standard). It is a file that describes every route, field, response and webhook, and works as the agreement between the team that builds the API and those who integrate with it. With it, partners generate documentation and code and can test before delivery.
Out of scope for this RFC
- Templates, campaigns and segmentation (Marketing API, later phases).
- Per-conversation pricing and billing.
- Groups, voice and video calls.
- Official SDKs (may come later, generated from the OpenAPI spec).
06Domain model
The merchant account becomes the root of everything. The merchant talks to the PhizChat user in two ways:
- Direct message (default). The merchant sends as itself, with the name and photo from Merchant information. No channel needed.
- Through a channel (optional). Merchants that have channels can send through them, for example to separate brands or departments.
Integrations belong to the merchant and receive permission to use either way or both.
flowchart LR M["Merchant account
merchant_id"] --> I["Integrations
Blip · Salesforce · own system
client_id + secret each"] I -- "POST /v1/{sender_id}/messages" --> API["PHIZ Business API"] API -- "Way 1 · default
sender_id = merchant_id" --> D["Direct message
from the merchant"] API -. "Way 2 · optional
sender_id = channel ID" .-> C["Message through
the merchant's channel"] D --> U(("PhizChat user
phone or PHIZ ID")) C --> U API -- "webhook: replies and statuses" --> I
Both ways use the same route and format; only the sender_id changes. Way 2 exists only for merchants that have a channel.
End-to-end flow
View for the engineering team: where a message travels from the business to the user, and how replies and statuses come back.
flowchart LR PP["PicPay"] --> B1["BSP
e.g. Blip"] AM["Ambar"] --> B2["BSP
e.g. Salesforce"] B1 --> API["PHIZ Business API
/gateway/business
Auth · Messages · Webhooks"] B2 --> API API -- "1–3 · token, send and
sender validation" --> MA subgraph OP["Open Platform"] MA["Merchant Account"] MA -- "4a · sender_id = merchant_id
default" --> DM["Direct Message"] MA -. "4b · sender_id = channel ID
optional" .-> CH["Channel"] CH --> CM["Channel Message"] end DM -- "5 · deliver" --> APP["Phiz App"] CM -- "5 · deliver" --> APP APP --> U(("User"))
Outbound: from the business to the user. The dashed line is the optional channel path.
Return: replies and statuses
flowchart LR
U(("User")) -- "replies, receives, reads" --> APP["Phiz App"]
APP -- "6 · events" --> WH["PHIZ Business API
Webhooks"]
WH -- "signed webhook" --> BSP["BSP of the integration
that owns the sender"]
BSP --> EMP["Business"]
Inbound: user replies and message statuses reach the BSP through the webhook.
- Integration. The business (for example PicPay or Ambar) uses a BSP for customer service. In the Developer area, the merchant creates an integration for that BSP and hands over the
client_idand secret. - Token. The BSP calls
/oauth/token. Auth checks the integration in the Merchant Account (secret, scopes and allowed senders) and returns a 1-hour JWT. - Send. The BSP calls
POST /v1/{sender_id}/messages. The API validates the token, scope, sender, opt-in policy andIdempotency-Key, answers202and queues the message. - Routing. With
sender_id = merchant_idthe message goes out as a Direct Message (default). With a channel ID it goes out as a Channel Message (optional). - Delivery. The Phiz App shows the message to the user and downloads media straight from the given link.
- Return. User replies and statuses (
sent,delivered,read,failed) become events that the Webhooks service delivers, signed, to the integration that owns the sender.
| Component | Responsibility | Status |
|---|---|---|
| BSP / business system | Calls the API, hosts media by link and receives the webhook. | External |
| PHIZ Business API · Auth | Issues and revokes JWTs; validates client_id and secret. | New |
| PHIZ Business API · Messages | Validates and accepts sends (202), applies idempotency and limits, queues and routes by sender_id. | New |
| PHIZ Business API · Webhooks | Consumes app events, signs and delivers them with retries; keeps 72 h of delivery history. | New |
| Open Platform · Merchant Account | Stores integrations, secrets (hashed), scopes, allowed senders and webhook settings. Hosts the Developer area. | Changed: credentials move from the channel to the merchant |
| Direct Message | Direct conversation between merchant and user in the app. | New (see Q3) |
| Channel / Channel Message | Sending through the merchant's channel. | Existing |
| Phiz App | Shows messages, downloads media by link and emits delivery, read and reply events. | Changed |
Mapping to Meta's Cloud API
| Meta (WhatsApp Cloud API) | PHIZ (this proposal) |
|---|---|
| WhatsApp Business Account (WABA) | Merchant account · merchant_id |
Business phone number · phone_number_id | Sender · sender_id: the merchant itself (default, equal to merchant_id) or, optionally, a channel (equal to the channel ID) |
| Meta app + System User token | Developer app + OAuth client credentials |
wa_id | phiz_id |
POST /{phone-number-id}/messages | POST /v1/{sender_id}/messages |
object: whatsapp_business_account | object: phiz_merchant_account |
X-Hub-Signature-256 | X-Phiz-Signature-256 + X-Phiz-Timestamp |
hub.mode / hub.verify_token / hub.challenge | Unchanged |
07Design principles
- Same shape as the Cloud API. Send bodies, responses and webhooks follow Meta's structure. We diverge only for a clear gain in security (timestamp in the signature) or clarity (
to_type). - HTTP status codes, a single error envelope and a
trace_idin every error response. - Sending is asynchronous and returns
202 Accepted; the outcome arrives by webhook. - Idempotency. The
Idempotency-Keyheader prevents duplicate messages when a client retries. - Least privilege. Each integration has scopes and a list of allowed senders.
- Versioning
/v1. Compatible changes (new fields) ship in a standardized way; breaking changes require/v2.
08Authentication
We use the OAuth 2.0 client credentials flow (RFC 6749 §4.4). The app exchanges client_id and client_secret for a 1-hour access token and sends it as Authorization: Bearer on every call. There is no refresh token: when the token expires, the app requests a new one.
/oauth/tokenIssues an access token/oauth/revokeRevokes a token (RFC 7009)/v1/meShows the token's integration, merchant, scopes and senders
sequenceDiagram
autonumber
participant S as External system
participant A as PHIZ Auth
participant API as PHIZ API
S->>A: POST /oauth/token (client_id, client_secret)
A-->>S: 200 access_token (1 h), scopes
S->>API: POST /v1/{sender_id}/messages · Bearer token
API-->>S: 202 Accepted
Close to expiry, the system requests a new token. There is no refresh token.
Request and response
# Base URL of this API: current PHIZ gateway + /gateway/business prefix (illustrative host)
PHIZ_API_BASE="https://gateway.phiz.chat/gateway/business"
curl -X POST "$PHIZ_API_BASE/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d client_id=app_2Fk81PzQ \
-d client_secret="$PHIZ_CLIENT_SECRET" \
-d "scope=messages:send messages:read"{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBoei0yMDI2LTEwIn0…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "messages:send messages:read",
"merchant_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57"
}The /oauth/* endpoints use the OAuth error format (RFC 6749 §5.2), which client libraries already understand:
{
"error": "invalid_client",
"error_description": "Client authentication failed."
}Token format
Decision: the token is a JWT signed with ES256, keys published at /.well-known/jwks.json. The gateway validates tokens locally without a database lookup per call. Revoked tokens and rotated secrets go into a short-lived deny list (until the token's expiry). For integrators the token is opaque; the content below is informative.
{
"iss": "https://gateway.phiz.chat/gateway/business",
"sub": "app_2Fk81PzQ",
"aud": "phiz-business-api",
"mid": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"scope": "messages:send messages:read",
"snd": [
"7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"1231"
],
"env": "live",
"iat": 1760527800,
"exp": 1760531400,
"jti": "tok_01JA9VQ4N2"
}Scopes
| Scope | Allows |
|---|---|
messages:send | Send messages and mark them as read |
messages:read | Query message status |
webhooks:manage | Set up, test and replay the app's own webhook |
account:read | Read merchant account and sender data |
templates:manage | Reserved for the Marketing API |
Secrets and rotation
- Secrets carry an environment prefix (
phzsk_live_,phzsk_test_) so leak scanners can recognise them. - A secret is shown only once, at creation or rotation, and stored only as a hash (Argon2id).
- Each app can have up to two active secrets. When a new one is generated, the previous one stays valid for a chosen period (default 24 h, from 0 to 7 days), so the switch causes no downtime.
- The screen shows when each secret was last used, so you know whether the old one is still in use before revoking it.
- Optional: an IP allowlist per app.
Current vs proposed
| Aspect | Today | Proposal |
|---|---|---|
| Credential owner | Channel | Merchant account (through an app) |
| Flow | user_credentials (custom) | client_credentials (RFC 6749) |
| Extra required fields | description | None |
| Lifetime | ~2 h + ~90-day refresh token | 1 h, no refresh token |
| Scopes | channel | Per capability + allowed senders |
| Rotation | Reset invalidates immediately | Two active secrets with overlap |
| Errors | HTTP 200 + code in the body | Real HTTP status + standard envelope |
09Sending messages
/v1/{sender_id}/messagesSends a message or marks one as read/v1/messages/{message_id}Latest known status (fallback)/v1/merchants/{merchant_id}/sendersSenders (merchant and channels) the integration may useSender: the merchant or a channel
The sender_id in the path defines on whose behalf the message is sent:
- The merchant itself (default). Use the
merchant_id. The user sees the message in a Business Account conversation, directly with the business, and the merchant does not need any channel. - A channel (optional). Use the channel ID, for example
1231. Useful for merchants that separate brands or departments into different channels. The message appears in the channel's Channel Service conversation, the same one Channel Bot uses.
Both work the same way in the API: same fields, same responses and same webhooks. In webhooks, metadata.sender_type shows which one was used and metadata.conversation_type carries the conversation type (business_account or channel_service).
Recipient: phone number or PHIZ ID
The to field holds the recipient and to_type says how to read it:
phone(default): an E.164 phone number such as+5511987654321. PHIZ resolves the number to the user's account.phiz_id: the user's PHIZ ID, such as839201447. This is the identifier webhooks carry, so replies can use it directly.
If the phone number does not belong to a PHIZ account, or the user has blocked the sender, the API still answers 202 and the webhook reports failed with error 2104. This keeps the API from being used to find out which numbers have a PHIZ account.
Example: text to a phone number
# sender_id = merchant_id: direct message from the merchant, no channel needed
curl -X POST "$PHIZ_API_BASE/v1/7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57/messages" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-48213-envio" \
-d '{
"messaging_product": "phiz",
"to": "+5511987654321",
"to_type": "phone",
"type": "text",
"text": { "body": "Olá, Maria! Seu pedido #48213 foi enviado." },
"biz_opaque_callback_data": "pedido=48213"
}'{
"messaging_product": "phiz",
"contacts": [
{
"input": "+5511987654321",
"phiz_id": null
}
],
"messages": [
{
"id": "pmid.01JA9VZ6T8R3",
"message_status": "accepted"
}
]
}contacts[].phiz_id is filled only if the user has already talked to this sender. messages[].id is the key to match status webhooks.
More examples
The same send, through a channel
# sender_id = 1231: same request, sent through the merchant's channel
curl -X POST "$PHIZ_API_BASE/v1/1231/messages" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "+5511987654321", "type": "text", "text": { "body": "Novidades da semana na Loja Exemplo - Ofertas" } }'Text to a PHIZ ID
{
"to": "839201447",
"to_type": "phiz_id",
"type": "text",
"text": {
"body": "Seu cupom chegou: PHIZ10"
}
}Audio by link
{
"to": "+5511987654321",
"type": "audio",
"audio": {
"link": "https://cdn.lojaexemplo.com.br/audios/aviso-entrega-48213.mp3"
}
}Video by link, to a PHIZ ID
{
"to": "839201447",
"to_type": "phiz_id",
"type": "video",
"video": {
"link": "https://cdn.lojaexemplo.com.br/videos/montagem-48213.mp4",
"caption": "Como montar seu produto"
}
}Document with a file name
{
"to": "+5511987654321",
"type": "document",
"document": {
"link": "https://cdn.lojaexemplo.com.br/nf/48213.pdf",
"filename": "nota-fiscal-48213.pdf",
"caption": "Nota fiscal do pedido #48213"
}
}Reply buttons
{
"to": "+5511987654321",
"type": "interactive",
"interactive": {
"type": "button",
"body": {
"text": "Confirma a entrega amanhã entre 9h e 12h?"
},
"action": {
"buttons": [
{
"type": "reply",
"reply": {
"id": "confirmar",
"title": "Confirmar"
}
},
{
"type": "reply",
"reply": {
"id": "reagendar",
"title": "Reagendar"
}
}
]
}
}
}Mark an inbound message as read
{
"messaging_product": "phiz",
"status": "read",
"message_id": "pmid.IN_01JA9W2C"
}Supported types
| type | Content | Phase |
|---|---|---|
text | Up to 4,096 characters; optional preview_url | MVP |
image | By HTTPS link; optional caption | MVP |
audio | By HTTPS link; no caption | MVP |
video | By HTTPS link; optional caption | MVP |
document | By HTTPS link; optional caption and file name | MVP |
Media formats and limits
Media is always sent by link: a public HTTPS URL hosted by the merchant or the partner. The messaging API does not process or store media; it only passes the link to the user's app, which downloads the file straight from that URL. The link must therefore stay reachable after sending, and the file must already be in a format and size the app accepts.
| Type | Accepted formats | Max size |
|---|---|---|
image | JPEG, PNG, WebP | 5 MB |
audio | AAC, MP3, M4A, OGG (Opus), AMR | 16 MB |
video | MP4 (H.264 + AAC), 3GP | 16 MB |
document | PDF, DOC/DOCX, XLS/XLSX, PPT/PPTX, TXT, CSV | 100 MB |
Formats and sizes the PHIZ app should accept, inspired by the Cloud API limits. Conversion and compression are up to whoever hosts the file. They must be confirmed with the team that owns media in the app (see Q8).
Sending policy (consent)
Proposal inspired by WhatsApp (see section 03), with the opt-in flows detailed in section 04:
- Starting a conversation requires opt-in from the user for that merchant (
merchant_id) and category: account alerts, offers or codes. Replying within 24 h of a user message needs no prior opt-in. - Opt-in can be collected outside PHIZ (the merchant's app, website or store) or inside the PHIZ app.
- Opt-out enforced by the platform: if the user blocks the merchant or stops offers, sends in that category fail with an error, without relying on the merchant.
- Record required: the merchant keeps proof of each consent (user,
merchant_id, category, source, date, wording shown and revocation). Under the LGPD, whoever processes the data must prove consent and honor withdrawal.
Meta has an opt-in policy but does not verify it: it trusts the merchant and reacts afterwards, through reports and the quality rating. We need to choose between A, the same model (policy plus a record kept by the merchant), and B, a consent registry in PHIZ, checked before every send. See Q1.
Idempotency
Send an Idempotency-Key with every message. The same key within 24 h returns the original response without sending again. The same key with a different body returns 409. This makes retries after a timeout or a 5xx safe.
10Webhooks
Each app has one webhook subscription with a URL, a verify_token and a list of events. It is configured in the Developer area or through the API.
/v1/apps/{app_id}/webhookSets URL, verify_token and events/v1/apps/{app_id}/webhookSettings and health/v1/apps/{app_id}/webhook/testFires a signed test event/v1/apps/{app_id}/webhook/deliveriesDelivery history (72 h)/v1/apps/{app_id}/webhook/deliveries/{id}/replayReplays a deliverycurl -X PUT "$PHIZ_API_BASE/v1/apps/app_2Fk81PzQ/webhook" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://integracao.lojaexemplo.com.br/phiz/webhook",
"verify_token": "um-texto-que-so-voce-conhece",
"fields": ["messages", "message_status"]
}'Events
| field | When |
|---|---|
messages | The user sent a message, tapped a button or reacted |
message_status | A sent message changed to sent, delivered, read or failed |
sender_update | A sender's status, quality or limit changed (merchant or channel) |
account_update | The merchant account changed (verification, restriction) |
URL verification
Before activating, PHIZ sends a GET to the URL with hub.mode=subscribe, hub.verify_token and hub.challenge. The server checks the token and answers 200 with the value of hub.challenge. It is the same handshake Meta uses.
Signature
Every POST carries the headers below. The signature is an HMAC-SHA256 of {X-Phiz-Timestamp}.{raw body} using the app's webhook secret, which is separate from the client secret. Reject events whose timestamp is more than 5 minutes off. While the webhook secret is being rotated, the header carries two comma-separated signatures.
POST /phiz/webhook HTTP/1.1
Host: integracao.lojaexemplo.com.br
Content-Type: application/json
User-Agent: PHIZ-Webhooks/1.0
X-Phiz-Delivery-Id: dlv_01JA9W2D7K
X-Phiz-Timestamp: 1760528527
X-Phiz-Signature-256: sha256=6f1c0e…// Express: verification handshake + signed events
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.raw({ type: "application/json" })); // keep the raw body for the HMAC
app.get("/phiz/webhook", (req, res) => {
const ok = req.query["hub.mode"] === "subscribe" &&
req.query["hub.verify_token"] === process.env.PHIZ_VERIFY_TOKEN;
return ok ? res.status(200).send(req.query["hub.challenge"]) : res.sendStatus(403);
});
app.post("/phiz/webhook", (req, res) => {
const ts = Number(req.get("X-Phiz-Timestamp"));
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.PHIZ_WEBHOOK_SECRET)
.update(`${ts}.${req.body}`)
.digest("hex");
const received = (req.get("X-Phiz-Signature-256") || "").split(",");
const valid = received.some(sig => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
if (!valid) return res.sendStatus(401);
queue.push({ deliveryId: req.get("X-Phiz-Delivery-Id"), event: JSON.parse(req.body) });
res.sendStatus(200); // answer fast, process asynchronously
});# Flask: signature check
import hmac, hashlib, time, os
from flask import request, abort
def verify_phiz_signature() -> None:
ts = int(request.headers["X-Phiz-Timestamp"])
if abs(time.time() - ts) > 300:
abort(400)
msg = f"{ts}.".encode() + request.get_data() # raw bytes, before JSON parsing
expected = "sha256=" + hmac.new(os.environ["PHIZ_WEBHOOK_SECRET"].encode(), msg, hashlib.sha256).hexdigest()
received = request.headers.get("X-Phiz-Signature-256", "").split(",")
if not any(hmac.compare_digest(sig, expected) for sig in received):
abort(401)Inbound message
{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"contacts": [
{
"profile": {
"name": "Maria Souza"
},
"phiz_id": "839201447",
"phone": "+5511987654321"
}
],
"messages": [
{
"from": "839201447",
"id": "pmid.IN_01JA9W2C",
"timestamp": "1760528527",
"type": "text",
"text": {
"body": "Quero reagendar a entrega"
},
"context": {
"id": "pmid.01JA9VZ6T8R3"
}
}
]
}
}
]
}
]
}Inbound message with media
When the user sends an image, audio, video or document, the event carries a temporary url pointing to the file in the current chat storage. The messaging API does not copy or keep the media; download the file while the link is valid. Below is the messages[] item of a voice message:
{
"from": "839201447",
"id": "pmid.IN_01JA9W7R",
"timestamp": "1760528731",
"type": "audio",
"audio": {
"url": "https://media.phiz.chat/in/9xT2…?expires=1760615131&sig=…",
"mime_type": "audio/ogg; codecs=opus",
"sha256": "3f9a…",
"voice": true
}
}Status of a sent message
{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "message_status",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"statuses": [
{
"id": "pmid.01JA9VZ6T8R3",
"status": "delivered",
"timestamp": "1760528410",
"recipient_id": "839201447",
"biz_opaque_callback_data": "pedido=48213",
"conversation": {
"id": "conv_01JA9W",
"origin": {
"type": "service"
},
"expires_at": "1760614810"
}
}
]
}
}
]
}
]
}{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "message_status",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"statuses": [
{
"id": "pmid.01JA9X0QW1",
"status": "failed",
"timestamp": "1760528600",
"recipient_id": "+5511900000000",
"errors": [
{
"code": 2104,
"title": "recipient_not_reachable",
"message": "The phone number is not linked to a PHIZ account or blocked this sender.",
"href": "https://developers.phiz.chat/docs/errors#2104"
}
]
}
]
}
}
]
}
]
}stateDiagram-v2 direction LR [*] --> accepted: API returns 202 accepted --> sent: reached PHIZ servers sent --> delivered: reached the device delivered --> read: user opened it accepted --> failed sent --> failed read --> [*] failed --> [*]
Status lifecycle. accepted appears only in the API response and in GET /v1/messages/{id}; the others arrive by webhook.
Delivery guarantees
- At least once. The same event may arrive more than once; use
X-Phiz-Delivery-Idto drop duplicates. - No ordering guarantee. A
readmay arrive beforedelivered; order bytimestampand never move a status backwards. - Answer within 10 s with a 2xx and process afterwards. Any other response, or a timeout, counts as a failure.
- Retries with exponential backoff (10 s, 30 s, 1 min, 5 min, 15 min, 1 h, then hourly) for up to 24 h. After that the event stays available for manual replay for 72 h.
- If the URL keeps failing for 24 h, the webhook moves to
failingand the merchant's administrators are notified.
11Errors, limits and versions
Outside /oauth/*, every error uses one envelope:
{
"error": {
"code": 1003,
"type": "invalid_parameter",
"message": "Field 'to' must be E.164 when to_type is 'phone'.",
"details": [
{
"field": "to",
"issue": "format"
}
],
"trace_id": "trc_9f2c41ab"
}
}| code | HTTP | type | Meaning |
|---|---|---|---|
| 1001 | 400 | invalid_request | Malformed JSON or missing required field |
| 1003 | 400 | invalid_parameter | Invalid value (see details) |
| 1101 | 401 | token_expired | Token expired; request a new one |
| 1102 | 401 | invalid_token | Invalid or revoked token |
| 1201 | 403 | insufficient_scope | The token lacks the required scope |
| 1202 | 403 | sender_not_allowed | The integration is not allowed to use this sender |
| 1301 | 404 | not_found | Resource does not exist or belongs to another merchant |
| 1401 | 409 | idempotency_conflict | Same Idempotency-Key with a different body |
| 1501 | 429 | rate_limited | Rate limit reached; honour Retry-After |
| 2104 | webhook | recipient_not_reachable | No PHIZ account for the number, or the user blocked the sender |
| 2201 | 422 | policy_violation | Sending not allowed by the opt-in policy |
| 2202 | 422 | unsupported_type | Message type not enabled for the sender |
| 2301 | webhook | media_unavailable | The app could not download the media from link (URL down, expired or unsupported format) |
| 2401 | 403 | sender_disabled | Sender disabled or restricted |
| 2501 | 429 | messaging_limit_reached | The sender's daily recipient limit was reached |
| 5000 | 500 | internal_error | Internal error; retry with the same Idempotency-Key |
Limits (initial proposal)
| Resource | Limit |
|---|---|
/oauth/token | 60 req/min per app |
| Sending messages | 80 msg/s per sender (adjustable by contract) |
| Other endpoints | 600 req/min per app |
| Unique recipients of business-initiated messages | Per-sender tiers: 1k → 10k → 100k → unlimited in 24 h, based on quality (Phase 2) |
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 80
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1760528529Environments
- Production:
https://{gateway}/gateway/business - Sandbox:
https://{test-gateway}/gateway/business, in the test environment used today (Openapi-test), with one test sender per merchant and up to 5 registered recipients (like Meta's test number).phzsk_test_secrets only work here.
Route prefix
The new API runs on the same gateway as the current API, separated by prefix:
/gateway/openapi/…: the current API, kept during the migration./gateway/business/…: this API (authentication, messages and webhooks).
With separate prefixes, each context has its own routing, authentication, limits and logs, and the legacy API can be shut down without affecting the new one. Every path in this document, such as /oauth/token and /v1/{sender_id}/messages, is relative to https://{gateway}/gateway/business. In the examples this address appears as $PHIZ_API_BASE.
12Developer area in the Open Platform
Developer settings move out of Channel › Development Settings into a merchant menu item, Developer, next to Settings. It reuses the existing Merchant ID, the roles from Team management and the Operation log.
Mockups of the new area, with sample values. Click an image to open it at full size.
Screens
Overview
/developer- Messages sent, delivered and failed in the last 24 h
- Webhook health
- Alerts: old secret still in use, webhook failing
Apps
/developer/apps- Create app: name, integration type (own, Blip, Salesforce, other), environment
- Scopes and allowed senders
- Client ID, secrets with rotation and last use
- IP allowlist (optional)
- Disable or delete app
App webhook
/developer/apps/:id/webhook- URL, verify token and events
- Webhook secret with rotation
- Verify and send a test
- Deliveries from the last 72 h with HTTP status, attempts, latency and replay
Senders
/developer/senders- The merchant itself, always present, plus channels if any
- Each with
sender_id, status, limit and quality - Picks the integration that receives each sender's conversations
- For channels, turns on “Phiz Business API” mode
API logs
/developer/logs- Requests from the last 7 days: method, route, status, latency,
trace_id - Filter by app and error code
Sandbox
/developer/sandbox- Test sender
- Up to 5 test recipients
phzsk_test_credentials
Permissions
New permissions are added to the existing Role management screen. The Super administrator gets all of them.
| Permission | Allows |
|---|---|
developer.view | View integrations, senders, logs and deliveries (no secrets) |
developer.apps.manage | Create, edit and disable integrations; set scopes and senders |
developer.secrets.manage | Generate, rotate and revoke secrets |
developer.webhooks.manage | Set up the webhook, test it and replay deliveries |
Creating, rotating or revoking a secret, changing scopes and editing the webhook all add an entry to the Operation log and email the administrators.
A sender with more than one integration
To keep two systems from answering the same user, each sender (the merchant or a channel) has one owning integration for conversations, chosen on the Senders screen. Only that integration receives the sender's messages event. Any integration with permission can send through it and receives message_status for the messages it sent. A conversation handover protocol between integrations (like Messenger's handover) is left for later.
Migrating channel credentials
- Automatic creation. For each channel with an AppID we create a “Legacy – channel name” app in the merchant, reusing the AppID as
client_id, the stored secret, and permission for that channel only. No integration breaks. - Coexistence.
/gateway/openapi/authand/token/refreshkeep working. The channel page shows a notice pointing to the new area. - Read only. After 3 months the channel page no longer allows resetting the secret; management happens only in the new area.
- Shutdown. After 6 months, and once measured usage is near zero, the old endpoints answer
410 Gone.
13Partner integrations
In the MVP, the merchant creates an app in the Developer area and pastes the client_id and secret into Blip, Salesforce or their own system. Webhook URLs come from the partner. Because the contracts follow the Cloud API, partners that already have a WhatsApp connector can reuse most of it.
In a later phase we create a technology partner program, equivalent to Meta's Embedded Signup:
- The partner registers a platform app with PHIZ.
- The merchant clicks “Connect with PHIZ” inside the partner's product and grants scopes and senders on a consent screen (OAuth 2.0 authorization code + PKCE).
- The partner receives tokens limited to that merchant and a single webhook for all its customers. The envelope's
entry[].ididentifies the merchant, which is why the envelope has that structure from day one.
14Path to the Marketing API
This RFC reserves the space the Marketing API will occupy, without implementing it:
type: templatefor sending and thetemplates:managescope already exist in the contract.- Templates with approval:
/v1/merchants/{merchant_id}/message_templatesand atemplate_statuswebhook event. - Conversation categories (
conversation.origin.type:service,utility,marketing), the basis for reporting and pricing. - Per-sender opt-in and opt-out management, campaigns to segments and metrics at
/v1/merchants/{merchant_id}/analytics. - Messaging limit tiers and a quality rating per sender, already present in the
Senderresource.
15Delivery plan
Authenticate and send
/oauth/token,/oauth/revoke,/v1/me- Send
text,image,audio,videoanddocumentto phone and PHIZ ID - Signed
messagesandmessage_statuswebhooks - Developer area: apps, secrets, webhook
- Default sender (the merchant itself) for everyone; channels as optional senders
- Automatic migration of channel credentials
Dates will be set after this RFC is reviewed with the China team.
16Open questions
- Opt-in policy. Section 09 proposes the policy (opt-in per merchant and category, opt-out enforced by the platform, record kept by the merchant). Still to decide: model A (like Meta, no verification) or B (a consent registry in PHIZ, for example
/v1/merchants/{merchant_id}/consents, checked on send); which categories exist; whether the PHIZ app shows users what they authorized; and how a user blocks or stops offers from a Business Account (see Q3). Validate the LGPD points with Legal. Applies to Business Account and Channel Service. - Sender IDs. Use the
merchant_idand the channel ID directly assender_id, or create new opaque IDs? Reusing existing IDs simplifies migration, but exposes the channel's sequential ID. - Business Account conversation type. Confirm the path Public Accounts › Service › Business Account and its definition: a two-way service conversation for businesses, merchants, governments and institutions, allowing user messages and a configurable Menu, with proactive messages subject to authorization and messaging rules. Does this type already exist in the app or does it need to be built? How does the user block or mute a Business Account?
- Current conversation types. Review the hierarchy and the permissions table (v1.2): which types exist today, which current rules differ, which types do not exist and which are missing.
- Phone numbers. Do all PHIZ users have a verified phone number? How do we handle foreign numbers and number changes?
- Is one owning integration per sender enough, or do we need conversation handover between apps (bot → human agent) already in Phase 2?
- Existing Business Message API. The AI Robot's “Phiz Business API” mode already has send and callback endpoints and seems to match Channel Service with Channel Bot. Does this RFC replace, extend or wrap that API?
- Media by link. Does the PHIZ app play every format in the media table, and which sizes does it accept? Does the app download straight from the merchant's URL (which exposes the user's IP to that server) or through a PHIZ proxy that stores nothing? Should we check the link before accepting the send (for example, type and size without downloading the file)? How long does the
urlof received media stay valid?
17OpenAPI reference
The OpenAPI 3.1 specification below covers every endpoint and webhook in this RFC, with descriptions in this language. “Try it out” is turned off because the API does not exist yet. Use “Copy OpenAPI” to take the YAML into Apifox, Postman or an SDK generator.
PHIZ Business Messaging API
商户级鉴权、向手机号或 PHIZ ID 发送消息,以及消息与状态 Webhook。参考 Meta Cloud API 设计,并作为后续 Marketing API 的基础。
01概要
本文提出一套公开 API,使外部系统(Blip、Salesforce、各类 CRM 或商户自有系统)能够代表商户与 PHIZ 用户对话。方案包含四个部分:
- OAuth 2.0 鉴权(客户端凭证模式),凭证签发给商户账号,不再绑定频道。
- 发送消息:直接以商户名义向手机号(E.164)或 PHIZ ID 发送消息,是否通过频道发送为可选项。
- 带签名的 Webhook:推送用户发来的消息,以及已发送消息的每一次状态变化。
- 开放平台中商户级的开发者中心:商户将每个调用 API 的外部系统(如 Blip 或 Salesforce)登记为一个集成。每个集成拥有独立的
client_id和密钥、Webhook 及投递记录。集成与小程序(Miniapp)无关。
消息与 Webhook 的数据结构沿用 Meta Cloud API,仅替换领域名称。已对接 WhatsApp Business Platform 的系统,只需修改 URL、凭证和少量字段名即可适配。
02背景:现状
以下内容基于现有文档(Apifox 的 Authentication 模块)和开放平台(open.phiz.chat)。
现有鉴权方式
通过 POST /gateway/openapi/auth 获取令牌,参数为 app_id、app_secret、description(必填)和 grant_type: "user_credentials"。响应包含约 2 小时有效的访问令牌和约 90 天有效的刷新令牌,刷新接口为 POST /gateway/openapi/token/refresh。
POST /gateway/openapi/auth
Content-Type: application/json
{
"app_id": "69c37cb1821121af34201f48",
"app_secret": "94B18B920D19463C7264BD0BDE30C31…",
"description": "push news",
"grant_type": "user_credentials",
"device_fingerprint": ""
}
200 OK
{
"code": 200,
"msg": "success",
"data": {
"scope": "channel",
"status": "active",
"access_token": "at_1770343926041_1Y3iz6XN…",
"token_type": "Bearer",
"expires_in": 7199,
"refresh_token": "rt_1770343926041_mxc7RV9…",
"access_token_expires_at": "2026-02-06T04:…",
"refresh_token_expires_at": "2026-05-07T02:…",
"created_at": "2026-02-05T10:15:23.672+00:00"
},
"success": true
}凭证所在位置
在开放平台中,每个频道都有 频道 › 开发配置 页面,显示 AppID 和 AppSecret。密钥不会明文显示,只能重置。频道的 AI 机器人 中还提供 “Phiz Business API” 回复模式,将频道会话交给 Business Message API 发送并通过 Webhook 回调。
商户层面已有商户 ID(UUID),以及包含角色、成员和操作记录的团队管理。新功能将以这三者为基础。
本 RFC 带来的变化
每一行分别说明现状、存在的问题、本方案的做法,以及一个具体使用场景。
| 现状 | 问题所在 | 本 RFC 的做法 | 使用场景 |
|---|---|---|---|
| 商户要通过 API 联系用户,必须拥有频道。 | 没有频道、或希望以企业本身身份沟通的商户,无法直接给用户发消息。 | 商户可以以自身名义直接发送消息,频道变为可选项。 | 一家没有频道的药店通过手机号通知顾客 Maria 订单已可取货。消息出现在她与药店的直接对话中。 |
| 每个频道都有自己的 AppID 和密钥。 | 有 5 个频道的商户要管理 5 套凭证,Blip 需要为每个频道单独获取令牌。 | 每个集成一套凭证,归属商户账号,可使用直接发送及选定的频道。 | Loja Exemplo 有 3 个频道并使用 Blip。现在要在 Blip 中录入 3 个 AppID;采用本 RFC 后,只需创建一个 “Blip” 集成,勾选直接发送和这 3 个频道。 |
| “重置”会立即使密钥失效。 | 在外部系统更新密钥之前,集成一直不可用。 | 两个密钥可同时有效:新密钥生效后,旧密钥在一段时间内仍可用(默认 24 小时)。 | Salesforce 的密钥出现在日志中。团队生成新密钥,下午更新 Salesforce,当天结束时撤销旧密钥,没有丢失任何消息。 |
登录使用 grant_type: user_credentials,并要求填写 description。 | Salesforce 等原生支持 OAuth 的工具,必须写定制代码才能接入。 | 标准 OAuth 2.0(client_credentials),这些工具开箱即用。 | Salesforce 管理员在 Named Credentials 中只需填写令牌 URL、client_id 和密钥即可连接 PHIZ,无需开发人员。 |
| 存在有效期约 90 天的刷新令牌。 | 多了一个需要保管的长期密钥,对服务端之间的集成没有好处。 | 1 小时令牌,不提供刷新令牌;过期后重新申请。 | Blip 每小时用同一组 client_id 和密钥自动续期令牌,不会出现刷新令牌在周末过期、导致客服中断的情况。 |
| 没有配置 Webhook、查看日志或管理多个集成的页面。 | 商户无法自行配置 Webhook,也无法自行排查集成为何中断。 | 开发者中心:独立的集成、Webhook、投递记录和日志。 | 顾客回复不再进入 Blip。商户打开投递记录,发现 Webhook 从上午 10 点起一直返回 500,修复后重放这些事件。 |
03参考:WhatsApp 上的用户体验
本 RFC 将 WhatsApp Business Platform 的模式引入 PHIZ。为了让巴西和中国团队对“我们要复刻的是什么”有相同的理解,本节展示一位巴西用户在 WhatsApp 上的完整旅程:如何注册账号、如何授权一家企业、消息如何到达,以及收到后能做什么。示例:商户 PicPay,用户 Ana Souza(圣保罗)。
这些是示意图,并非 WhatsApp 官方界面。画面文字已译为中文(真实应用中用户看到的是葡萄牙语)。编号(A2、O1、C3…)与本节来源的设计画布一致。点击图片可查看原尺寸。
消息路径
企业不直接对接 WhatsApp:企业系统(CRM、客服)调用 BSP(Blip、Zenvia、Twilio、Infobip),BSP 调用 Meta 的 Cloud API,再投递到用户手机。回复、点击和状态沿同一路径通过 Webhook 返回。在 PHIZ 中,PHIZ 商家消息 API 对应 Cloud API,商户账户(merchant_id)对应 WABA(对照见第 06 节)。
用户注册
在 WhatsApp 上,账号就是经短信验证的手机号。没有好友申请:知道号码就能发消息。企业和家人在同一个收件箱里,因此企业消息受到严格管控。自 2026 年起,WhatsApp 提供用户名和企业范围用户 ID(BSUID)来替代手机号,这与本 RFC 中使用 PHIZ ID 的思路一致。
用户授权(opt-in)
企业主动发起会话之前,用户必须已授权该企业。常见方式有四种:在企业自己的 App 中(O1)、注册或结账时(O2)、用户先发消息,仅覆盖这次客服会话(O3),以及在聊天中回答“是”(O4)。最佳实践是按用途分开:同意账户提醒不等于同意营销优惠。
Meta 有政策,但不验证 opt-in。没有用于证明授权的字段或接口;Meta 信任企业,只在事后通过屏蔽、举报和质量评级作出反应。而 opt-out 由平台强制执行:屏蔽、“停止优惠”和营销上限都会使发送失败。尽管如此,根据巴西 LGPD,企业必须能证明已获得同意并尊重撤回,因此实际上必须保存记录(谁、商户、类别、来源、时间、展示的文案、撤回)。
用户收到什么
企业主动发送的消息使用经审核的模板,分四类:交易通知(付款、账单)、营销、验证码和客服。每一类都依赖对应的 opt-in。
用户能做什么
用户可以回复(文字、按钮、列表、表单)、查看企业资料,或撤回授权。每个操作可能会、也可能不会向企业产生事件。
| 用户操作 | PHIZ | |
|---|---|---|
| 回复文字或媒体 | messages Webhook;开启 24 小时窗口 | message 事件(第 10 节) |
| 点击按钮或列表项 | button_reply / list_reply | 带所选 id 的 message 事件 |
| 停止优惠 | 营销消息发送失败 | 提议:平台对该 merchant_id 屏蔽此类别 |
| 屏蔽或举报 | 所有发送失败;质量评级下降 | 提议:发送报错并产生质量信号(见 Q3) |
| 静音或归档 | 企业不可见 | 相同 |
对 PHIZ 的启示
- 整个流程中企业的身份是商户 ID(
merchant_id):opt-in、屏蔽和质量评级都归属于它。不存在单独的开发者 ID;集成和应用只是代表商户执行操作。 - 按商户、按类别管理同意,opt-out 由平台执行(见第 09 节发送策略)。
- 用户信号(屏蔽、举报、停止优惠)影响商户的发送额度。
- 聊天内表单(Flows)可作为小程序的参考。
04PhizChat 中的 opt-in:应用内与企业侧
第 03 节说明了在 WhatsApp 上 opt-in 几乎都发生在应用之外,而且 Meta 并不核实。本节提议用户在 PhizChat 中如何授权企业账号,共有两条路径,最终都使用相同的退订控制:
- 流程 P——在 PhizChat 内:用户在应用中直接接受企业账号,由平台保存证明。
- 流程 Q——在企业侧、PhizChat 之外:用户在商户的 App、网站或门店中授权,由商户发起对话。
这些是示意性提议,以 PicPay 作为示例商户。编号(P0…P8、Q0…Q5)与设计画布一致。点击图片可查看原尺寸。
流程 P · 在 PhizChat 内 opt-in
用户通过短信或邮件链接、二维码、商户 App 或网站,或应用内搜索找到企业账号。未安装 Phiz 时,只会看到带资料和下载按钮的 H5 页面;接受永远不会在应用之外发生。安装并登录后,延迟深度链接把用户带回同一个对话,该对话在接受之前保持锁定。用户接受后,PhizChat 记录同意(用户、merchant_id、日期、入口),如有欢迎模板则发送,并解锁对话。
接受之后,资料页显示同意状态和日期。用户可以撤回(对话回到锁定状态,之后可以重新授权),或屏蔽(沿用现有屏蔽功能;API 仍可调用,但会返回已屏蔽状态)。
流程 Q · 企业在 PhizChat 之外收集的 opt-in
大多数巴西企业已经在自己的渠道中收集同意。在流程 Q 中,用户在 PicPay App 中开启“PhizChat 消息”;PicPay 保存证明,并通过 API 发送模板。API 检查收件人是否为 Phiz 用户、是否已屏蔽或撤回;在模式 B 中,还会检查该类别是否有已登记的同意。在应用中,第一条消息会附带同意来源卡片(授权了什么、在哪里、何时),用户可以继续接收、停止或举报。
P 与 Q 对比
| P · 在 PhizChat 内 opt-in | Q · 在企业处 opt-in | |
|---|---|---|
| 由谁收集 | PhizChat(接受) | 商户(App、网站、门店) |
| 证明保存在 | PhizChat | 商户 CRM(模式 A)· 同时在 PHIZ(模式 B) |
| 首次对话 | 接受之前锁定 | 以消息和同意来源打开 |
| 退订 | 两者相同:撤回、屏蔽、举报——由 PhizChat 执行 | |
待决定事项
- 流程 Q 采用模式 A 还是 B(见 Q1):像 Meta 一样只有政策,还是在 PHIZ 中建立同意登记并在每次发送时校验。完整的同意来源卡片(Q3)和按类别显示的状态(Q4)依赖模式 B。
- 流程 Q 中接受之前是否投递:本提议会投递第一条消息并显示同意来源卡片;另一种做法是像流程 P 一样,在接受之前锁定对话。
- 同意类别(账户提醒、优惠、验证码)以及应用向用户展示什么。
- 技术命名——
/v1/merchants/{merchant_id}/consents、consent_updateWebhook 和错误码——均为待讨论的占位内容。
05目标与非目标
目标
- 凭证归属商户账号,不再按频道签发。商户为每个外部系统创建一个集成(例如 Blip 一个、Salesforce 一个)。每个集成拥有独立的
client_id和密钥,由商户决定它能做什么(发送消息、查询状态、配置 Webhook)以及能以哪些发送方身份沟通:商户本身,以及商户可选的频道。 - 采用标准 OAuth 2.0(客户端凭证模式),兼容主流库和平台。
- 向手机号或 PHIZ ID 发送消息,并通过签名 Webhook 接收回复和状态。
- 与 Meta Cloud API 结构兼容,现有集成只需少量改动即可迁移。
- 以 OpenAPI 3.1 格式(即 Swagger 标准)发布 API 规范。该文件描述全部路由、字段、响应和 Webhook,是 API 开发团队与集成方之间的约定。合作方可据此生成文档和代码,并在交付前进行测试。
不在本 RFC 范围内
- 模板、营销活动和用户分群(Marketing API,后续阶段)。
- 按会话计费与结算。
- 群组、语音和视频通话。
- 官方 SDK(后续可基于 OpenAPI 生成)。
06领域模型
商户账号成为一切的根节点。商户可以通过两种方式与 PhizChat 用户沟通:
- 直接消息(默认)。商户以自身名义发送,使用 商户信息 中的名称和头像,无需频道。
- 通过频道(可选)。拥有频道的商户可通过频道发送,例如用于区分品牌或部门。
集成隶属于商户,可被授权使用其中一种或两种方式。
flowchart LR M["商户账号
merchant_id"] --> I["集成
Blip · Salesforce · 自有系统
各有 client_id + 密钥"] I -- "POST /v1/{sender_id}/messages" --> API["PHIZ Business API"] API -- "方式 1 · 默认
sender_id = merchant_id" --> D["商户直接消息"] API -. "方式 2 · 可选
sender_id = 频道 ID" .-> C["通过商户频道
发送的消息"] D --> U(("PhizChat 用户
手机号或 PHIZ ID")) C --> U API -- "Webhook:回复与状态" --> I
两种方式使用相同的路由和格式,只有 sender_id 不同。方式 2 仅适用于拥有频道的商户。
端到端流程
面向技术团队:消息从企业到用户经过哪些环节,回复和状态又如何返回。
flowchart LR PP["PicPay"] --> B1["BSP
如 Blip"] AM["Ambar"] --> B2["BSP
如 Salesforce"] B1 --> API["PHIZ Business API
/gateway/business
Auth · Messages · Webhooks"] B2 --> API API -- "1–3 · 令牌、发送
与发送方校验" --> MA subgraph OP["Open Platform"] MA["Merchant Account"] MA -- "4a · sender_id = merchant_id
默认" --> DM["Direct Message"] MA -. "4b · sender_id = 频道 ID
可选" .-> CH["Channel"] CH --> CM["Channel Message"] end DM -- "5 · 投递" --> APP["Phiz App"] CM -- "5 · 投递" --> APP APP --> U(("用户"))
去程:从企业到用户。虚线为通过频道发送的可选路径。
返回:回复与状态
flowchart LR
U(("用户")) -- "回复、接收、阅读" --> APP["Phiz App"]
APP -- "6 · 事件" --> WH["PHIZ Business API
Webhooks"]
WH -- "签名 Webhook" --> BSP["负责该发送方的
集成所属 BSP"]
BSP --> EMP["企业"]
回程:用户回复和消息状态通过 Webhook 送达 BSP。
- 集成。企业(如 PicPay 或 Ambar)通过 BSP 提供客服。商户在开发者中心为该 BSP 创建集成,并提供
client_id和密钥。 - 令牌。BSP 调用
/oauth/token。Auth 在 Merchant Account 中校验集成(密钥、范围和可用发送方),返回 1 小时有效的 JWT。 - 发送。BSP 调用
POST /v1/{sender_id}/messages。API 校验令牌、范围、发送方、选择接收策略和Idempotency-Key,返回202并将消息入队。 - 路由。
sender_id = merchant_id时作为 Direct Message 发送(默认);使用频道 ID 时作为 Channel Message 发送(可选)。 - 投递。Phiz App 向用户展示消息,并直接从提供的链接下载媒体。
- 返回。用户回复和状态(
sent、delivered、read、failed)生成事件,由 Webhooks 服务签名后投递给负责该发送方的集成。
| 组件 | 职责 | 状态 |
|---|---|---|
| BSP / 企业系统 | 调用 API,托管媒体链接,接收 Webhook。 | 外部 |
| PHIZ Business API · Auth | 签发和撤销 JWT;校验 client_id 和密钥。 | 新增 |
| PHIZ Business API · Messages | 校验并受理发送(202),处理幂等和限流,入队并按 sender_id 路由。 | 新增 |
| PHIZ Business API · Webhooks | 消费 App 事件,签名并带重试投递;保留 72 小时投递记录。 | 新增 |
| Open Platform · Merchant Account | 存储集成、密钥(哈希)、范围、可用发送方和 Webhook 配置;承载开发者中心。 | 变更:凭证从频道迁移到商户 |
| Direct Message | App 内商户与用户的直接对话。 | 新增(见 Q3) |
| Channel / Channel Message | 通过商户频道发送。 | 现有 |
| Phiz App | 展示消息,通过链接下载媒体,产生送达、已读和回复事件。 | 变更 |
与 Meta Cloud API 的对应关系
| Meta(WhatsApp Cloud API) | PHIZ(本方案) |
|---|---|
| WhatsApp Business Account(WABA) | 商户账号 · merchant_id |
企业号码 · phone_number_id | 发送方 · sender_id:商户本身(默认,等于 merchant_id),或可选的频道(等于频道 ID) |
| Meta 应用 + System User 令牌 | 开发者应用 + OAuth 客户端凭证 |
wa_id | phiz_id |
POST /{phone-number-id}/messages | POST /v1/{sender_id}/messages |
object: whatsapp_business_account | object: phiz_merchant_account |
X-Hub-Signature-256 | X-Phiz-Signature-256 + X-Phiz-Timestamp |
hub.mode / hub.verify_token / hub.challenge | 保持一致 |
07设计原则
- 与 Cloud API 结构一致。发送请求、响应和 Webhook 均沿用 Meta 的结构,只有在安全性(签名带时间戳)或清晰度(
to_type)有明确收益时才有所不同。 - HTTP 状态码、统一的错误结构,所有错误响应都带
trace_id。 - 发送为异步处理,返回
202 Accepted,结果通过 Webhook 推送。 - 幂等。
Idempotency-Key请求头可避免客户端重试导致重复消息。 - 最小授权。每个集成都有范围和可用发送方列表。
- 版本号
/v1。兼容性变更(新增字段)按统一规范上线;不兼容变更需发布/v2。
08鉴权
采用 OAuth 2.0 客户端凭证模式(RFC 6749 §4.4)。应用用 client_id 和 client_secret 换取 1 小时有效的访问令牌,并在每次调用时通过 Authorization: Bearer 传递。不提供刷新令牌:令牌过期后重新申请即可。
/oauth/token签发访问令牌/oauth/revoke撤销令牌(RFC 7009)/v1/me查看令牌对应的集成、商户、范围和发送方
sequenceDiagram
autonumber
participant S as 外部系统
participant A as PHIZ 鉴权服务
participant API as PHIZ API
S->>A: POST /oauth/token(client_id, client_secret)
A-->>S: 200 access_token(1 小时)、范围
S->>API: POST /v1/{sender_id}/messages · Bearer 令牌
API-->>S: 202 Accepted
临近过期时重新申请令牌,不使用刷新令牌。
请求与响应
# Base URL of this API: current PHIZ gateway + /gateway/business prefix (illustrative host)
PHIZ_API_BASE="https://gateway.phiz.chat/gateway/business"
curl -X POST "$PHIZ_API_BASE/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d client_id=app_2Fk81PzQ \
-d client_secret="$PHIZ_CLIENT_SECRET" \
-d "scope=messages:send messages:read"{
"access_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6InBoei0yMDI2LTEwIn0…",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "messages:send messages:read",
"merchant_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57"
}/oauth/* 接口使用 OAuth 标准错误格式(RFC 6749 §5.2),客户端库可直接识别:
{
"error": "invalid_client",
"error_description": "Client authentication failed."
}令牌格式
决定:令牌采用 ES256 签名的 JWT,公钥发布在 /.well-known/jwks.json。网关在本地校验令牌,无需每次调用都查询数据库。被撤销的令牌和已轮换的密钥会进入短期拒绝列表(直到令牌过期)。对集成方而言令牌是不透明的,以下内容仅供参考。
{
"iss": "https://gateway.phiz.chat/gateway/business",
"sub": "app_2Fk81PzQ",
"aud": "phiz-business-api",
"mid": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"scope": "messages:send messages:read",
"snd": [
"7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"1231"
],
"env": "live",
"iat": 1760527800,
"exp": 1760531400,
"jti": "tok_01JA9VQ4N2"
}范围(scope)
| 范围 | 允许的操作 |
|---|---|
messages:send | 发送消息、标记已读 |
messages:read | 查询消息状态 |
webhooks:manage | 配置、测试和重放本应用的 Webhook |
account:read | 读取商户账号和发送方信息 |
templates:manage | 预留给 Marketing API |
密钥与轮换
- 密钥带环境前缀(
phzsk_live_、phzsk_test_),便于泄露扫描工具识别。 - 密钥仅在创建或轮换时显示一次,服务端只保存哈希(Argon2id)。
- 每个应用最多可有两个有效密钥。生成新密钥后,旧密钥在设定时间内仍然有效(默认 24 小时,可设 0 至 7 天),切换过程不中断。
- 页面显示每个密钥的最近使用时间,便于确认旧密钥已停用后再撤销。
- 可选:按应用配置 IP 白名单。
现状与方案对比
| 方面 | 现状 | 方案 |
|---|---|---|
| 凭证归属 | 频道 | 商户账号(通过应用) |
| 授权流程 | user_credentials(自定义) | client_credentials(RFC 6749) |
| 额外必填字段 | description | 无 |
| 有效期 | 约 2 小时 + 约 90 天刷新令牌 | 1 小时,无刷新令牌 |
| 范围 | channel | 按能力划分 + 可用发送方 |
| 轮换 | 重置后立即失效 | 两个有效密钥,平滑过渡 |
| 错误 | HTTP 200 + 正文中的 code | 真实 HTTP 状态 + 统一错误结构 |
09发送消息
/v1/{sender_id}/messages发送消息或标记已读/v1/messages/{message_id}最新已知状态(兜底)/v1/merchants/{merchant_id}/senders集成可使用的发送方(商户与频道)发送方:商户或频道
路径中的 sender_id 决定消息以谁的名义发出:
- 商户本身(默认)。使用
merchant_id。用户在 Business Account 会话中看到与企业的直接对话,商户无需拥有频道。 - 频道(可选)。使用频道 ID,例如
1231。适合按品牌或部门划分频道的商户。消息出现在该频道的 Channel Service 会话中,与 Channel Bot 使用的会话相同。
两者在 API 中的用法完全相同:字段、响应和 Webhook 一致。Webhook 中的 metadata.sender_type 标明使用的是哪一种,metadata.conversation_type 标明会话类型(business_account 或 channel_service)。
接收方:手机号或 PHIZ ID
to 字段填写接收方,to_type 指明其类型:
phone(默认):E.164 格式手机号,例如+5511987654321。PHIZ 会将号码解析为对应的用户账号。phiz_id:用户的 PHIZ ID,例如839201447。Webhook 中携带的就是这个标识,回复时可直接使用。
如果手机号没有对应的 PHIZ 账号,或用户屏蔽了该发送方,API 仍返回 202,Webhook 会推送 failed 和错误码 2104。这样可以防止通过 API 探测哪些号码注册了 PHIZ。
示例:向手机号发送文本
# sender_id = merchant_id: direct message from the merchant, no channel needed
curl -X POST "$PHIZ_API_BASE/v1/7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57/messages" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: pedido-48213-envio" \
-d '{
"messaging_product": "phiz",
"to": "+5511987654321",
"to_type": "phone",
"type": "text",
"text": { "body": "Olá, Maria! Seu pedido #48213 foi enviado." },
"biz_opaque_callback_data": "pedido=48213"
}'{
"messaging_product": "phiz",
"contacts": [
{
"input": "+5511987654321",
"phiz_id": null
}
],
"messages": [
{
"id": "pmid.01JA9VZ6T8R3",
"message_status": "accepted"
}
]
}只有当用户曾与该发送方对话过,contacts[].phiz_id 才会有值。messages[].id 用于关联状态 Webhook。
更多示例
同样的消息,通过频道发送
# sender_id = 1231: same request, sent through the merchant's channel
curl -X POST "$PHIZ_API_BASE/v1/1231/messages" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "to": "+5511987654321", "type": "text", "text": { "body": "Novidades da semana na Loja Exemplo - Ofertas" } }'向 PHIZ ID 发送文本
{
"to": "839201447",
"to_type": "phiz_id",
"type": "text",
"text": {
"body": "Seu cupom chegou: PHIZ10"
}
}通过链接发送音频
{
"to": "+5511987654321",
"type": "audio",
"audio": {
"link": "https://cdn.lojaexemplo.com.br/audios/aviso-entrega-48213.mp3"
}
}通过链接向 PHIZ ID 发送视频
{
"to": "839201447",
"to_type": "phiz_id",
"type": "video",
"video": {
"link": "https://cdn.lojaexemplo.com.br/videos/montagem-48213.mp4",
"caption": "Como montar seu produto"
}
}带文件名的文档
{
"to": "+5511987654321",
"type": "document",
"document": {
"link": "https://cdn.lojaexemplo.com.br/nf/48213.pdf",
"filename": "nota-fiscal-48213.pdf",
"caption": "Nota fiscal do pedido #48213"
}
}快捷回复按钮
{
"to": "+5511987654321",
"type": "interactive",
"interactive": {
"type": "button",
"body": {
"text": "Confirma a entrega amanhã entre 9h e 12h?"
},
"action": {
"buttons": [
{
"type": "reply",
"reply": {
"id": "confirmar",
"title": "Confirmar"
}
},
{
"type": "reply",
"reply": {
"id": "reagendar",
"title": "Reagendar"
}
}
]
}
}
}将收到的消息标记为已读
{
"messaging_product": "phiz",
"status": "read",
"message_id": "pmid.IN_01JA9W2C"
}支持的消息类型
| type | 内容 | 阶段 |
|---|---|---|
text | 最多 4,096 个字符;可选 preview_url | MVP |
image | 通过 HTTPS link;可选说明文字 | MVP |
audio | 通过 HTTPS link;不支持说明文字 | MVP |
video | 通过 HTTPS link;可选说明文字 | MVP |
document | 通过 HTTPS link;可选说明文字和文件名 | MVP |
媒体格式与限制
媒体一律通过 link 发送:由商户或合作方托管的公开 HTTPS 链接。消息 API 不处理也不存储媒体,只把链接传给用户的 App,由 App 直接从该地址下载文件。因此链接在发送后需持续可访问,文件也必须事先符合 App 支持的格式和大小。
| 类型 | 支持格式 | 大小上限 |
|---|---|---|
image | JPEG、PNG、WebP | 5 MB |
audio | AAC、MP3、M4A、OGG(Opus)、AMR | 16 MB |
video | MP4(H.264 + AAC)、3GP | 16 MB |
document | PDF、DOC/DOCX、XLS/XLSX、PPT/PPTX、TXT、CSV | 100 MB |
PHIZ App 应支持的格式和大小,参考 Cloud API 的限制。格式转换和压缩由文件托管方负责。需与负责 App 媒体能力的团队确认(见 Q8)。
发送策略(同意)
参考 WhatsApp 的提议(见第 03 节),opt-in 流程详见第 04 节:
- 主动发起会话需要 opt-in:用户需针对该商户(
merchant_id)和类别(账户提醒、优惠或验证码)授权。用户发消息后 24 小时内回复,无需事先 opt-in。 - opt-in 可以在 PHIZ 之外收集(商户的 App、网站或门店),也可以在 PHIZ App 内收集。
- opt-out 由平台执行:用户屏蔽商户或停止优惠后,该类别的发送直接报错失败,不依赖商户处理。
- 必须保存记录:商户为每次同意保存证明(用户、
merchant_id、类别、来源、时间、展示的文案及撤回)。根据 LGPD,数据处理方须证明已获得同意并尊重撤回。
Meta 有 opt-in 政策但不做验证:它信任商户,事后通过举报和质量评级作出反应。我们需要在 A(相同模式:政策 + 商户自行记录)和 B(在 PHIZ 中建立同意登记,并在每次发送前校验)之间做出选择。见 Q1。
幂等
每次发送都应携带 Idempotency-Key。24 小时内使用相同的键会直接返回原始响应,不会重复发送;相同的键配不同的请求体则返回 409。因此在超时或 5xx 后重试是安全的。
10Webhook
每个应用有一个 Webhook 订阅,包含 URL、verify_token 和事件列表,可在开发者中心或通过 API 配置。
/v1/apps/{app_id}/webhook设置 URL、verify_token 和事件/v1/apps/{app_id}/webhook配置与健康状态/v1/apps/{app_id}/webhook/test推送带签名的测试事件/v1/apps/{app_id}/webhook/deliveries投递记录(72 小时)/v1/apps/{app_id}/webhook/deliveries/{id}/replay重放某次投递curl -X PUT "$PHIZ_API_BASE/v1/apps/app_2Fk81PzQ/webhook" \
-H "Authorization: Bearer $PHIZ_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://integracao.lojaexemplo.com.br/phiz/webhook",
"verify_token": "um-texto-que-so-voce-conhece",
"fields": ["messages", "message_status"]
}'事件
| field | 触发时机 |
|---|---|
messages | 用户发送消息、点击按钮或回应表情 |
message_status | 已发送消息变为 sent、delivered、read 或 failed |
sender_update | 发送方(商户或频道)的状态、质量或额度发生变化 |
account_update | 商户账号发生变化(认证、限制) |
URL 验证
启用前,PHIZ 会向 URL 发起 GET 请求,携带 hub.mode=subscribe、hub.verify_token 和 hub.challenge。服务器校验令牌后返回 200,正文为 hub.challenge 的值。与 Meta 的握手方式相同。
签名
每个 POST 都带有以下请求头。签名是使用应用 webhook secret(独立于 client secret)对 {X-Phiz-Timestamp}.{原始请求体} 计算的 HMAC-SHA256。时间戳偏差超过 5 分钟的事件应拒绝。轮换 webhook secret 期间,请求头会包含以逗号分隔的两个签名。
POST /phiz/webhook HTTP/1.1
Host: integracao.lojaexemplo.com.br
Content-Type: application/json
User-Agent: PHIZ-Webhooks/1.0
X-Phiz-Delivery-Id: dlv_01JA9W2D7K
X-Phiz-Timestamp: 1760528527
X-Phiz-Signature-256: sha256=6f1c0e…// Express: verification handshake + signed events
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.raw({ type: "application/json" })); // keep the raw body for the HMAC
app.get("/phiz/webhook", (req, res) => {
const ok = req.query["hub.mode"] === "subscribe" &&
req.query["hub.verify_token"] === process.env.PHIZ_VERIFY_TOKEN;
return ok ? res.status(200).send(req.query["hub.challenge"]) : res.sendStatus(403);
});
app.post("/phiz/webhook", (req, res) => {
const ts = Number(req.get("X-Phiz-Timestamp"));
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.sendStatus(400);
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.PHIZ_WEBHOOK_SECRET)
.update(`${ts}.${req.body}`)
.digest("hex");
const received = (req.get("X-Phiz-Signature-256") || "").split(",");
const valid = received.some(sig => sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
if (!valid) return res.sendStatus(401);
queue.push({ deliveryId: req.get("X-Phiz-Delivery-Id"), event: JSON.parse(req.body) });
res.sendStatus(200); // answer fast, process asynchronously
});# Flask: signature check
import hmac, hashlib, time, os
from flask import request, abort
def verify_phiz_signature() -> None:
ts = int(request.headers["X-Phiz-Timestamp"])
if abs(time.time() - ts) > 300:
abort(400)
msg = f"{ts}.".encode() + request.get_data() # raw bytes, before JSON parsing
expected = "sha256=" + hmac.new(os.environ["PHIZ_WEBHOOK_SECRET"].encode(), msg, hashlib.sha256).hexdigest()
received = request.headers.get("X-Phiz-Signature-256", "").split(",")
if not any(hmac.compare_digest(sig, expected) for sig in received):
abort(401)收到的消息
{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "messages",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"contacts": [
{
"profile": {
"name": "Maria Souza"
},
"phiz_id": "839201447",
"phone": "+5511987654321"
}
],
"messages": [
{
"from": "839201447",
"id": "pmid.IN_01JA9W2C",
"timestamp": "1760528527",
"type": "text",
"text": {
"body": "Quero reagendar a entrega"
},
"context": {
"id": "pmid.01JA9VZ6T8R3"
}
}
]
}
}
]
}
]
}收到的媒体消息
用户发送图片、音频、视频或文档时,事件中包含一个指向现有聊天存储中该文件的临时 url。消息 API 不复制也不保存媒体,请在链接有效期内下载。下面是一条语音消息的 messages[] 条目:
{
"from": "839201447",
"id": "pmid.IN_01JA9W7R",
"timestamp": "1760528731",
"type": "audio",
"audio": {
"url": "https://media.phiz.chat/in/9xT2…?expires=1760615131&sig=…",
"mime_type": "audio/ogg; codecs=opus",
"sha256": "3f9a…",
"voice": true
}
}已发送消息的状态
{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "message_status",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"statuses": [
{
"id": "pmid.01JA9VZ6T8R3",
"status": "delivered",
"timestamp": "1760528410",
"recipient_id": "839201447",
"biz_opaque_callback_data": "pedido=48213",
"conversation": {
"id": "conv_01JA9W",
"origin": {
"type": "service"
},
"expires_at": "1760614810"
}
}
]
}
}
]
}
]
}{
"object": "phiz_merchant_account",
"entry": [
{
"id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"changes": [
{
"field": "message_status",
"value": {
"messaging_product": "phiz",
"metadata": {
"sender_id": "7f3e2a10-5b8c-4d21-9e6f-2a1c0b9d4e57",
"sender_type": "merchant",
"conversation_type": "business_account",
"display_name": "Loja Exemplo"
},
"statuses": [
{
"id": "pmid.01JA9X0QW1",
"status": "failed",
"timestamp": "1760528600",
"recipient_id": "+5511900000000",
"errors": [
{
"code": 2104,
"title": "recipient_not_reachable",
"message": "The phone number is not linked to a PHIZ account or blocked this sender.",
"href": "https://developers.phiz.chat/docs/errors#2104"
}
]
}
]
}
}
]
}
]
}stateDiagram-v2 direction LR [*] --> accepted: API 返回 202 accepted --> sent: 已到达 PHIZ 服务器 sent --> delivered: 已到达设备 delivered --> read: 用户已打开 accepted --> failed sent --> failed read --> [*] failed --> [*]
状态生命周期。accepted 只出现在 API 响应和 GET /v1/messages/{id} 中,其余状态通过 Webhook 推送。
投递保证
- 至少一次。同一事件可能多次到达,请用
X-Phiz-Delivery-Id去重。 - 不保证顺序。
read可能早于delivered到达;请按timestamp排序,状态不可回退。 - 10 秒内返回 2xx,再异步处理。其他响应或超时均视为失败。
- 重试采用指数退避(10 秒、30 秒、1 分钟、5 分钟、15 分钟、1 小时,之后每小时一次),最长 24 小时。之后事件在 72 小时内可手动重放。
- 若 URL 连续 24 小时失败,Webhook 状态变为
failing,并通知商户管理员。
11错误、限额与版本
除 /oauth/* 外,所有错误使用统一结构:
{
"error": {
"code": 1003,
"type": "invalid_parameter",
"message": "Field 'to' must be E.164 when to_type is 'phone'.",
"details": [
{
"field": "to",
"issue": "format"
}
],
"trace_id": "trc_9f2c41ab"
}
}| code | HTTP | type | 含义 |
|---|---|---|---|
| 1001 | 400 | invalid_request | JSON 格式错误或缺少必填字段 |
| 1003 | 400 | invalid_parameter | 参数值无效(见 details) |
| 1101 | 401 | token_expired | 令牌已过期,请重新申请 |
| 1102 | 401 | invalid_token | 令牌无效或已撤销 |
| 1201 | 403 | insufficient_scope | 令牌缺少所需范围 |
| 1202 | 403 | sender_not_allowed | 集成无权使用该发送方 |
| 1301 | 404 | not_found | 资源不存在或属于其他商户 |
| 1401 | 409 | idempotency_conflict | 相同 Idempotency-Key 但请求体不同 |
| 1501 | 429 | rate_limited | 超出速率限制,请遵循 Retry-After |
| 2104 | webhook | recipient_not_reachable | 号码无 PHIZ 账号,或用户屏蔽了发送方 |
| 2201 | 422 | policy_violation | 选择接收(opt-in)策略不允许此次发送 |
| 2202 | 422 | unsupported_type | 该发送方未开通此消息类型 |
| 2301 | webhook | media_unavailable | App 无法从 link 下载媒体(链接失效、过期或格式不支持) |
| 2401 | 403 | sender_disabled | 发送方已停用或受限 |
| 2501 | 429 | messaging_limit_reached | 发送方当日接收方额度已用完 |
| 5000 | 500 | internal_error | 内部错误,请使用相同 Idempotency-Key 重试 |
限额(初步建议)
| 资源 | 限额 |
|---|---|
/oauth/token | 每个应用 60 次/分钟 |
| 发送消息 | 每个发送方 80 条/秒(可按合同调整) |
| 其他接口 | 每个应用 600 次/分钟 |
| 企业主动消息的唯一接收方 | 按发送方分级:24 小时内 1 千 → 1 万 → 10 万 → 不限,依质量评级调整(第 2 阶段) |
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 80
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1760528529环境
- 生产环境:
https://{gateway}/gateway/business - 沙箱环境:
https://{测试网关}/gateway/business,即目前使用的测试环境(Openapi-test),每个商户一个测试发送方,最多登记 5 个测试接收方(类似 Meta 的测试号码)。phzsk_test_密钥仅在此可用。
路由前缀
新 API 与现有 API 部署在同一网关上,通过前缀区分:
/gateway/openapi/…:现有 API,迁移期间保留。/gateway/business/…:本 API(鉴权、消息和 Webhook)。
前缀分开后,两个上下文各自拥有独立的路由、鉴权、限额和日志,旧接口下线也不会影响新 API。本文中的所有路径(如 /oauth/token 和 /v1/{sender_id}/messages)均相对于 https://{gateway}/gateway/business。示例中该地址写作 $PHIZ_API_BASE。
12开放平台开发者中心
开发配置从 频道 › 开发配置 移出,成为商户菜单中的独立入口 开发者,位于 设置 旁边。它复用现有的商户 ID、团队管理 中的角色以及 操作记录。
新区域的界面示意,数值均为示例。点击图片可查看原图。
页面
概览
/developer- 最近 24 小时已发送、已送达和失败的消息数
- Webhook 健康状态
- 提醒:旧密钥仍在使用、Webhook 失败
应用
/developer/apps- 创建应用:名称、集成类型(自有、Blip、Salesforce、其他)、环境
- 范围和可用发送方
- Client ID、可轮换的密钥及最近使用时间
- IP 白名单(可选)
- 停用或删除应用
应用 Webhook
/developer/apps/:id/webhook- URL、verify token 和事件
- 可轮换的 webhook secret
- 验证并发送测试
- 最近 72 小时投递记录:HTTP 状态、重试次数、延迟、重放
发送方
/developer/senders- 商户本身(始终存在),以及已有的频道
- 每个发送方的
sender_id、状态、额度和质量 - 为每个发送方指定接收会话的集成
- 频道可开启 “Phiz Business API” 模式
API 日志
/developer/logs- 最近 7 天的请求:方法、路由、状态、延迟、
trace_id - 按应用和错误码筛选
沙箱
/developer/sandbox- 测试发送方
- 最多 5 个测试接收方
phzsk_test_凭证
权限
在现有的 角色管理 页面中新增以下权限,超级管理员默认拥有全部权限。
| 权限 | 允许的操作 |
|---|---|
developer.view | 查看集成、发送方、日志和投递记录(不含密钥) |
developer.apps.manage | 创建、编辑和停用集成;设置范围和发送方 |
developer.secrets.manage | 生成、轮换和撤销密钥 |
developer.webhooks.manage | 配置 Webhook、测试及重放投递 |
创建、轮换或撤销密钥,修改范围和 Webhook,都会写入 操作记录 并邮件通知管理员。
一个发送方对应多个集成
为避免两个系统同时回复同一用户,每个发送方(商户或频道)有一个负责会话的集成,在 发送方 页面中指定。只有该集成会收到此发送方的 messages 事件。任何获得授权的集成都可以通过它发送消息,并收到自己所发消息的 message_status。集成之间的会话移交协议(类似 Messenger 的 handover)留待后续。
频道凭证迁移
- 自动创建。为每个已有 AppID 的频道,在商户下创建 “Legacy – 频道名称” 应用,沿用原 AppID 作为
client_id、沿用已存储的密钥,并仅授权该频道。现有集成不受影响。 - 并行期。
/gateway/openapi/auth和/token/refresh继续可用,频道页面显示提示,引导至新入口。 - 只读。3 个月后,频道页面不再允许重置密钥,统一在新入口管理。
- 下线。6 个月后,且实际调用量接近零时,旧接口返回
410 Gone。
13合作伙伴集成
MVP 阶段,商户在开发者中心创建应用,把 client_id 和密钥填入 Blip、Salesforce 或自有系统,Webhook URL 由合作方提供。由于契约沿用 Cloud API,已有 WhatsApp 连接器的合作方可以复用大部分实现。
后续阶段推出技术合作伙伴计划,对应 Meta 的 Embedded Signup:
- 合作伙伴在 PHIZ 注册平台应用。
- 商户在合作方产品中点击 “连接 PHIZ”,在授权页面确认范围和发送方(OAuth 2.0 授权码模式 + PKCE)。
- 合作伙伴获得仅限该商户的令牌,并用一个 Webhook 接收所有客户的事件。外层结构中的
entry[].id标识商户,因此该结构从一开始就这样设计。
14通往 Marketing API
本 RFC 为 Marketing API 预留空间,但不做实现:
- 契约中已包含发送用的
type: template和templates:manage范围。 - 需审核的模板:
/v1/merchants/{merchant_id}/message_templates,以及template_statusWebhook 事件。 - 会话类别(
conversation.origin.type:service、utility、marketing),作为报表与计费的基础。 - 按发送方管理选择接收与退订,面向分群的营销活动,以及
/v1/merchants/{merchant_id}/analytics指标。 - 按发送方的发送额度分级和质量评级,已体现在
Sender资源中。
15交付计划
鉴权与发送
/oauth/token、/oauth/revoke、/v1/me- 向手机号和 PHIZ ID 发送
text、image、audio、video和document - 带签名的
messages和message_statusWebhook - 开发者中心:应用、密钥、Webhook
- 所有商户的默认发送方(商户本身);频道作为可选发送方
- 频道凭证自动迁移
具体时间将在与中国团队评审本 RFC 后确定。
16待讨论问题
- 选择接收(opt-in)策略。第 09 节已给出策略提议(按商户和类别 opt-in、平台执行 opt-out、商户保存记录)。仍需决定:模式 A(与 Meta 相同,不验证)还是 B(在 PHIZ 中建立同意登记,例如
/v1/merchants/{merchant_id}/consents,发送时校验);有哪些类别;PHIZ App 是否向用户展示其授权内容;以及用户如何屏蔽 Business Account 或停止其优惠(见 Q3)。LGPD 相关要点需与法务确认。适用于 Business Account 和 Channel Service。 - 发送方 ID。直接使用
merchant_id和频道 ID 作为sender_id,还是新建不透明 ID?沿用现有 ID 便于迁移,但会暴露频道的自增 ID。 - Business Account 会话类型。确认路径 公众号 › Service › Business Account 及其定义:面向企业、商户、政府和机构的双向服务会话,允许用户发消息、菜单可配置,主动消息需遵守授权与发送规则。该类型在 App 中已存在还是需要新建?用户如何屏蔽或免打扰某个 Business Account?
- 现有会话类型。核对层级图和权限表(v1.2):哪些类型现已存在、哪些现行规则不同、哪些类型不存在、还缺少哪些类型。
- 手机号。所有 PHIZ 用户都有已验证的手机号吗?境外号码和换号如何处理?
- 每个发送方一个负责集成是否足够,还是第 2 阶段就需要应用间会话移交(机器人 → 人工客服)?
- 现有 Business Message API。AI 机器人的 “Phiz Business API” 模式已有发送和回调接口,似乎对应带 Channel Bot 的 Channel Service。本 RFC 是替代、扩展还是封装这套 API?
- 通过链接发送媒体。PHIZ App 能否播放媒体表中的所有格式,支持多大的文件?App 是直接从商户的链接下载(会向该服务器暴露用户 IP),还是经由不存储内容的 PHIZ 代理?是否应在受理前校验链接(例如不下载文件,仅检查类型和大小)?收到的媒体
url有效期多长?
17OpenAPI 参考
下方 OpenAPI 3.1 规范覆盖本 RFC 的全部接口和 Webhook,描述为当前语言。由于 API 尚未上线,“Try it out” 已关闭。可通过 “复制 OpenAPI” 将 YAML 导入 Apifox、Postman 或 SDK 生成器。






































































































































