Entrar Criar conta
Guias

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.

  1. No painel, em Integrações › CRM, escolha Outro CRM (webhook) e dê um nome. Ou use POST /crm-connections com provider: "generic".
  2. Configure o seu sistema para enviar as mudanças das pessoas, assinadas, para a webhook_url.
  3. Mapeie os campos colando um exemplo do que o seu sistema envia e conferindo o resultado antes de salvar.
  4. 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.
  5. 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:

JeitoCabeçalhosComo 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 corpoX-Postal-Signaturesha256= + 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:

Enviar uma pessoa, assinada

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,
]);
202Resposta
{
  "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.

RespostaQuando
202 acceptedRecebido; será processado.
401 invalid_signatureAssinatura ou token ausente ou errado, ou data fora dos 5 minutos.
404 not_foundA conexão não existe (ou o ID não é um UUID).
409 connection_pausedA conexão está pausada ou só envia.
413 payload_too_largeCorpo acima de 256 KB.
422 validation_errorO 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:

CampoO que é
emailO 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_idO ID do registro no seu sistema. A Postal o liga ao contato e o devolve nos envios.
first_name, last_nameNome e sobrenome.
unsubscribedVerdadeiro ou falso (não reinscreve quem já se descadastrou).
tagsLista de etiquetas.
propertiesObjeto com as propriedades de contato; as chaves precisam existir no time.
event, event_propertiesO nome de um evento a registrar no contato (letras, números e . _ : -, até 100 caracteres) e os dados dele. Pode iniciar automações.
Corpo (uma pessoa)
{
  "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.

POST /crm-connections/{id}/preview

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);
200Resposta
{
  "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.

PATCH /crm-connections/{id}
{
  "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_at e data, como nos webhooks da Postal. Os cabeçalhos trazem User-Agent: Postal-CRM/1.0 e a assinatura.
  • Os campos de data seguem 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 {}.

contact.updated
{
  "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.

email.opened
{
  "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:

Receber da Postal

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/rotate e 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-Token em vez de ?token=.
  • Use settings.skip_sources (por exemplo my-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.