Conexão de CRM por webhook
Ligue qualquer CRM ou sistema que envie e receba webhooks: receba pessoas com assinatura, mapeie os campos com um exemplo e envie contatos e atividade de e-mail para a sua URL.
#Visão geral
A conexão por webhook liga a Postal a qualquer CRM ou sistema que consiga enviar e receber webhooks (um POST com JSON): um CRM sem conexão nativa, um ERP, uma planilha automatizada, o seu próprio backend. A Postal cria a URL e o segredo; você diz como os campos se ligam. Em Conexões de CRM estão os conceitos comuns: sentido, mapeamento, registro e novas tentativas.
- No painel, em Integrações › CRM, escolha Outro CRM (webhook) e dê um nome. Ou use
POST /crm-connectionscomprovider: "generic". - Configure o seu sistema para enviar as mudanças das pessoas, assinadas, para a
webhook_url. - Mapeie os campos colando um exemplo do que o seu sistema envia e conferindo o resultado antes de salvar.
- Para a Postal enviar as mudanças de contatos e a atividade dos e-mails, informe a URL HTTPS do seu sistema e quais eventos quer.
- Use Testar envio e acompanhe tudo no registro.
Depende do plano
Como as demais conexões de CRM, esta também conta no limite crm_connections do plano de marketing. Veja Limites.
#Receber do seu sistema
O seu sistema envia um POST com um corpo JSON (objeto ou lista) para a webhook_url da conexão, https://api.postal.so/integrations/crm/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/webhook, com a assinatura. A Postal responde 202 na hora e processa em segundo plano; o resultado de cada pessoa fica no registro.
Assinatura
A Postal confere, nesta ordem, um destes jeitos. O segredo é o webhook_secret (whsec_…) da conexão:
| Jeito | Cabeçalhos | Como calcular |
|---|---|---|
| Standard Webhooks (o mesmo dos webhooks da Postal) | webhook-id, webhook-timestamp, webhook-signature (ou svix-*) | v1, + Base64 do HMAC-SHA256 de {id}.{timestamp}.{corpo}, com a chave sendo o segredo sem whsec_ e decodificado de Base64. A data não pode ter mais de 5 minutos de diferença. Serve qualquer biblioteca Standard Webhooks ou Svix. |
| Hash do corpo | X-Postal-Signature | sha256= + hexadecimal do HMAC-SHA256 do corpo cru, com o segredo inteiro, com o whsec_, como chave. É o mais simples para quem só sabe calcular um hash. |
| Segredo como token (desligado por padrão) | X-Postal-Token ou ?token= | O próprio segredo. Só vale se a conexão tiver settings.inbound.allow_token ligado (“Aceitar o segredo como token” no painel), para ferramentas que não sabem assinar. Menos seguro: o segredo viaja em cada chamada e uma URL com ?token= costuma ir parar em logs. |
Se os cabeçalhos do Standard Webhooks vierem, só eles valem; os outros jeitos entram quando eles faltam. Exemplo com o hash do corpo:
cURL
# o segredo da conexão (whsec_…), usado como está
SECRET="whsec_..."
URL="https://api.postal.so/integrations/crm/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/webhook"
BODY='{"email":"[email protected]","first_name":"Ana"}'
SIGNATURE="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -X POST "$URL" \
-H "Content-Type: application/json" \
-H "X-Postal-Signature: $SIGNATURE" \
-d "$BODY"
Node.js
import crypto from 'node:crypto';
const secret = process.env.POSTAL_CRM_SECRET; // o segredo da conexão (whsec_…), usado como está
const body = JSON.stringify({ email: '[email protected]', first_name: 'Ana' }); // exatamente os bytes que você envia
const signature = 'sha256=' + crypto.createHmac('sha256', secret).update(body).digest('hex');
const response = await fetch(process.env.POSTAL_CRM_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Postal-Signature': signature },
body,
});
// 202 { "object": "crm_webhook", "id": "...", "status": "accepted" }
Python
import hashlib
import hmac
import json
import os
import requests
secret = os.environ["POSTAL_CRM_SECRET"] # o segredo da conexão (whsec_…), usado como está
body = json.dumps({"email": "[email protected]", "first_name": "Ana"}).encode() # exatamente os bytes que você envia
signature = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
response = requests.post(
os.environ["POSTAL_CRM_WEBHOOK_URL"],
data=body,
headers={"Content-Type": "application/json", "X-Postal-Signature": signature},
timeout=10,
)
PHP
<?php
$secret = getenv('POSTAL_CRM_SECRET'); // o segredo da conexão (whsec_…), usado como está
$body = json_encode(['email' => '[email protected]', 'first_name' => 'Ana']); // exatamente os bytes que você envia
$signature = 'sha256='.hash_hmac('sha256', $body, $secret);
$client = new \GuzzleHttp\Client();
$response = $client->post(getenv('POSTAL_CRM_WEBHOOK_URL'), [
'headers' => ['Content-Type' => 'application/json', 'X-Postal-Signature' => $signature],
'body' => $body,
]);
{
"object": "crm_webhook",
"id": "019a2c10-5e6f-7a81-9b2c-3d4e5f6a7b8c",
"status": "accepted"
}
O id é o da entrada do registro. Assine o mesmo texto que vai no corpo: se o seu cliente reescrever o JSON depois de assinar, a conferência falha.
| Resposta | Quando |
|---|---|
202 accepted | Recebido; será processado. |
401 invalid_signature | Assinatura ou token ausente ou errado, ou data fora dos 5 minutos. |
404 not_found | A conexão não existe (ou o ID não é um UUID). |
409 connection_paused | A conexão está pausada ou só envia. |
413 payload_too_large | Corpo acima de 256 KB. |
422 validation_error | O corpo não é um objeto ou uma lista JSON. |
#Formato do corpo
Sem mapeamentos, a Postal lê o corpo no seu formato: um objeto com uma pessoa, ou uma lista de objetos. Os campos reconhecidos:
| Campo | O que é |
|---|---|
email | O e-mail. É obrigatório: um registro sem e-mail (e sem vínculo nem external_id que o encontrem) falha. |
external_id (ou user_id) | O ID da pessoa no seu app. |
crm_id | O ID do registro no seu sistema. A Postal o liga ao contato e o devolve nos envios. |
first_name, last_name | Nome e sobrenome. |
unsubscribed | Verdadeiro ou falso (não reinscreve quem já se descadastrou). |
tags | Lista de etiquetas. |
properties | Objeto com as propriedades de contato; as chaves precisam existir no time. |
event, event_properties | O nome de um evento a registrar no contato (letras, números e . _ : -, até 100 caracteres) e os dados dele. Pode iniciar automações. |
{
"email": "[email protected]",
"first_name": "Ana",
"last_name": "Souza",
"external_id": "user_8421",
"crm_id": "51",
"tags": [
"lead",
"newsletter"
],
"properties": {
"plan": "pro"
},
"event": "deal.won",
"event_properties": {
"amount": 1200
}
}
Se o seu sistema envia o corpo em outra forma, use os mapeamentos. Para webhooks com várias pessoas dentro de um objeto, settings.inbound.items_path aponta a lista (por exemplo $.data.items[*]); sem ele, o corpo todo é um registro, ou uma lista de registros. Um webhook processa até 500 pessoas. settings.inbound.event_field aponta o campo do registro cujo valor é o nome do evento; o registro inteiro vai como dados do evento.
#Mapear os campos
Quando o corpo do seu sistema não é o da Postal, mapeie cada campo com settings.field_mappings e, se houver funil, settings.stage. As regras (campos da Postal, caminhos, sentido de cada mapeamento, etapas) são as de Mapear campos e etapas. No painel, cole um exemplo do que o sistema envia: os caminhos aparecem como sugestão, e Ver o resultado do exemplo mostra as pessoas que sairiam dele.
Pela API, POST /crm-connections/{id}/preview faz o mesmo sem gravar nada: manda o payload e, se quiser testar antes de salvar, as settings. Guardar o exemplo em settings.sample_payload (até 32 KB) faz GET /crm-connections/{id}/fields listar os campos dele.
cURL
curl -X POST https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/preview \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"payload": {
"data": {
"items": [
{
"person": {
"id": 7,
"mail": "[email protected]",
"name": "Ana"
},
"deal": {
"stage": "Won"
},
"labels": "vip, quente"
}
]
}
},
"settings": {
"inbound": {
"items_path": "$.data.items[*]"
},
"field_mappings": [
{
"crm_field": "$.person.mail",
"postal_field": "email"
},
{
"crm_field": "$.person.id",
"postal_field": "crm_id"
},
{
"crm_field": "person.name",
"postal_field": "first_name"
},
{
"crm_field": "$.labels",
"postal_field": "tags"
}
],
"stage": {
"crm_field": "$.deal.stage",
"mappings": [
{
"stage": "won",
"property_key": "lifecycle",
"property_value": "customer",
"tag": "customer"
}
]
}
}
}'
Node.js
const response = await fetch('https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/preview', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
payload: {
data: {
items: [
{
person: {
id: 7,
mail: '[email protected]',
name: 'Ana',
},
deal: {
stage: 'Won',
},
labels: 'vip, quente',
},
],
},
},
settings: {
inbound: {
items_path: '$.data.items[*]',
},
field_mappings: [
{
crm_field: '$.person.mail',
postal_field: 'email',
},
{
crm_field: '$.person.id',
postal_field: 'crm_id',
},
{
crm_field: 'person.name',
postal_field: 'first_name',
},
{
crm_field: '$.labels',
postal_field: 'tags',
},
],
stage: {
crm_field: '$.deal.stage',
mappings: [
{
stage: 'won',
property_key: 'lifecycle',
property_value: 'customer',
tag: 'customer',
},
],
},
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/preview",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"payload": {
"data": {
"items": [
{
"person": {
"id": 7,
"mail": "[email protected]",
"name": "Ana",
},
"deal": {
"stage": "Won",
},
"labels": "vip, quente",
},
],
},
},
"settings": {
"inbound": {
"items_path": "$.data.items[*]",
},
"field_mappings": [
{
"crm_field": "$.person.mail",
"postal_field": "email",
},
{
"crm_field": "$.person.id",
"postal_field": "crm_id",
},
{
"crm_field": "person.name",
"postal_field": "first_name",
},
{
"crm_field": "$.labels",
"postal_field": "tags",
},
],
"stage": {
"crm_field": "$.deal.stage",
"mappings": [
{
"stage": "won",
"property_key": "lifecycle",
"property_value": "customer",
"tag": "customer",
},
],
},
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('crm-connections/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/preview', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'payload' => [
'data' => [
'items' => [
[
'person' => [
'id' => 7,
'mail' => '[email protected]',
'name' => 'Ana',
],
'deal' => [
'stage' => 'Won',
],
'labels' => 'vip, quente',
],
],
],
],
'settings' => [
'inbound' => [
'items_path' => '$.data.items[*]',
],
'field_mappings' => [
[
'crm_field' => '$.person.mail',
'postal_field' => 'email',
],
[
'crm_field' => '$.person.id',
'postal_field' => 'crm_id',
],
[
'crm_field' => 'person.name',
'postal_field' => 'first_name',
],
[
'crm_field' => '$.labels',
'postal_field' => 'tags',
],
],
'stage' => [
'crm_field' => '$.deal.stage',
'mappings' => [
[
'stage' => 'won',
'property_key' => 'lifecycle',
'property_value' => 'customer',
'tag' => 'customer',
],
],
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "crm_preview",
"fields": [
{
"path": "$.data.items[0].person.id",
"sample": 7
},
{
"path": "$.data.items[0].person.mail",
"sample": "[email protected]"
},
{
"path": "$.data.items[0].person.name",
"sample": "Ana"
},
{
"path": "$.data.items[0].deal.stage",
"sample": "Won"
},
{
"path": "$.data.items[0].labels",
"sample": "vip, quente"
}
],
"people": [
{
"email": "[email protected]",
"first_name": "Ana",
"properties": {
"lifecycle": "customer"
},
"tags": ["vip", "quente", "customer"],
"crm_id": "7",
"events": []
}
]
}
Aqui, lifecycle é uma propriedade de contato que já existe. O resultado vem na forma de um contato da Postal; nada foi criado.
Salvar a configuração completa
Com o exemplo funcionando, grave tudo de uma vez. Lembre que settings substitui o que havia: envie o conjunto inteiro.
{
"settings": {
"direction": "both",
"inbound": {
"items_path": "$.data.items[*]",
"event_field": "$.event",
"allow_token": false
},
"field_mappings": [
{
"crm_field": "$.person.mail",
"postal_field": "email",
"direction": "both"
},
{
"crm_field": "$.person.id",
"postal_field": "crm_id",
"direction": "inbound"
},
{
"crm_field": "$.person.name",
"postal_field": "first_name",
"direction": "both"
},
{
"crm_field": "$.labels",
"postal_field": "tags",
"direction": "inbound"
}
],
"outbound": {
"url": "https://crm.exemplo.com.br/postal",
"events": [
"contact.created",
"contact.updated",
"email.opened",
"email.clicked"
]
},
"skip_sources": [
"my-app"
]
}
}
#Enviar para o seu sistema
Com settings.outbound.url preenchida, a Postal faz um POST assinado nessa URL para cada mudança de contato e atividade de e-mail que a conexão assinou em settings.outbound.events (o padrão e a lista completa estão em Conexões de CRM).
- A URL tem de ser HTTPS e pública: endereços que resolvem para rede privada são recusados, e redirecionamentos não são seguidos. O tempo limite é de 10 segundos.
- Tudo vai em JSON, com três campos:
type,created_atedata, como nos webhooks da Postal. Os cabeçalhos trazemUser-Agent: Postal-CRM/1.0e a assinatura. - Os campos de
dataseguem os seus mapeamentos de envio; sem mapeamentos, o formato é o da Postal.
Pessoas
contact.created e contact.updated levam em data a pessoa. crm_id é o ID que o seu sistema devolveu antes (ou null). Uma lista de propriedades vazia chega como [], não {}.
{
"type": "contact.updated",
"created_at": "2026-10-26T14:05:10.123Z",
"data": {
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_8421",
"crm_id": "51",
"first_name": "Ana",
"last_name": "Souza",
"unsubscribed": false,
"properties": {
"plan": "pro"
},
"tags": ["customer"]
}
}
Atividade de e-mail
Os eventos email.* levam a pessoa em data.contact e, em data.email, o data do evento da Postal. contact.deleted também vem assim: data.email traz o contato como era antes de ser excluído, e crm_id vem null.
{
"type": "email.opened",
"created_at": "2026-10-26T14:20:41.530Z",
"data": {
"contact": {
"id": "019a1e00-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
"email": "[email protected]",
"external_id": "user_8421",
"crm_id": "51",
"first_name": "Ana",
"last_name": "Souza",
"unsubscribed": false,
"properties": {
"plan": "pro"
},
"tags": ["customer"]
},
"email": {
"email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"created_at": "2026-10-26T14:01:00.000Z",
"from": "Loja Exemplo <[email protected]>",
"to": ["[email protected]"],
"subject": "Seu pedido #10482 saiu para entrega",
"tags": {
"category": "shipping"
}
}
}
}
O que responder
Qualquer 2xx vale como entregue. Se a resposta for JSON com id ou crm_id (texto ou número), a Postal guarda esse ID como o registro da pessoa no seu sistema e o manda de volta em crm_id nas próximas mudanças dela. Qualquer outra resposta, ou falta de resposta, é uma falha: a Postal registra o status e o começo do corpo (até 200 caracteres) e tenta de novo depois de 30 segundos, 2 minutos, 10 minutos, 30 minutos e 2 horas. Responda rápido e processe em segundo plano. Como pode haver reenvios, trate as entregas de forma idempotente, usando o webhook-id.
Conferir a assinatura
A Postal assina com o mesmo segredo, do jeito do Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature, e os mesmos valores em svix-*): veja Verificar a assinatura. Também vai o X-Postal-Signature (sha256= + HMAC-SHA256 do corpo cru, com o segredo inteiro como chave), mais simples de conferir:
Node.js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.POSTAL_CRM_SECRET; // o segredo da conexão (whsec_…)
app.post('/postal', express.raw({ type: 'application/json' }), (req, res) => {
// req.body: o corpo cru, antes do JSON.parse
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(req.body).digest('hex');
const received = req.get('X-Postal-Signature') || '';
if (received.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
return res.sendStatus(401);
}
const { type, data } = JSON.parse(req.body);
// type: contact.updated, email.opened, ... | data: o registro ou a atividade
res.json({ id: '51' }); // opcional: o ID da pessoa no seu sistema
});
Python
import hashlib
import hmac
import os
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
SECRET = os.environ["POSTAL_CRM_SECRET"] # o segredo da conexão (whsec_…)
@app.post("/postal")
def postal():
body = request.get_data() # bytes, exatamente como chegaram
expected = "sha256=" + hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(request.headers.get("X-Postal-Signature", ""), expected):
abort(401)
event = request.get_json() # event["type"], event["data"]
return jsonify(id="51") # opcional: o ID da pessoa no seu sistema
PHP
<?php
$secret = getenv('POSTAL_CRM_SECRET'); // o segredo da conexão (whsec_…)
$body = file_get_contents('php://input'); // o corpo cru
$expected = 'sha256='.hash_hmac('sha256', $body, $secret);
if (! hash_equals($expected, $_SERVER['HTTP_X_POSTAL_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true); // $event['type'], $event['data']
header('Content-Type: application/json');
echo json_encode(['id' => '51']); // opcional: o ID da pessoa no seu sistema
Testar
Testar envio (POST /crm-connections/{id}/test) manda um evento postal.test para a URL, e a resposta diz se o seu sistema respondeu e com qual status. O corpo é {"type": "postal.test", "created_at": "…", "data": {"connection_id": "…"}}. Sem URL de envio, o teste só avisa que a conexão recebe, e não envia. Enviar todos os contatos (POST /crm-connections/{id}/sync) manda cada contato do time como contact.updated.
#Segurança e boas práticas
- Guarde o segredo como uma chave: só no servidor. Se vazar, gere outro com
POST /crm-connections/{id}/webhook-secret/rotatee atualize o seu sistema; o anterior para de valer na hora. - Prefira a assinatura ao token. Se precisar do token, use o cabeçalho
X-Postal-Tokenem vez de?token=. - Use
settings.skip_sources(por exemplomy-app) para a Postal não devolver ao seu sistema as mudanças que ele mesmo mandou pela API. As mudanças desta conexão (crm:<id>) nunca voltam para ela. Veja Sem loops. - Teste com poucos contatos antes de Enviar todos os contatos, e acompanhe o registro: ele mostra o motivo de cada falha e permite reenviar.
- Limites e erros: veja Conexões de CRM.