Conexión de CRM por webhook
Conecta cualquier CRM o sistema que envíe y reciba webhooks: recibe personas con firma, mapea los campos con un ejemplo y envía contactos y actividad de correo a tu URL.
#Visión general
La conexión por webhook conecta Postal con cualquier CRM o sistema que pueda enviar y recibir webhooks (un POST con JSON): un CRM sin conexión nativa, un ERP, una hoja de cálculo automatizada, tu propio backend. Postal crea la URL y el secreto; tú indicas cómo se enlazan los campos. En Conexiones de CRM están los conceptos comunes: sentido, mapeo, registro y reintentos.
- En el panel, en Integraciones › CRM, elige Otro CRM (webhook) y ponle un nombre. O usa
POST /crm-connectionsconprovider: "generic". - Configura tu sistema para enviar los cambios de las personas, firmados, a la
webhook_url. - Mapea los campos pegando un ejemplo de lo que envía tu sistema y comprobando el resultado antes de guardar.
- Para que Postal envíe los cambios de contactos y la actividad de los correos, indica la URL HTTPS de tu sistema y qué eventos quieres.
- Usa Probar envío y sigue todo en el registro.
Depende del plan
Como las demás conexiones de CRM, esta también cuenta en el límite crm_connections del plan de marketing. Consulta Límites.
#Recibir de tu sistema
Tu sistema envía un POST con un cuerpo JSON (objeto o lista) a la webhook_url de la conexión, https://api.postal.so/integrations/crm/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/webhook, con la firma. Postal responde 202 al instante y procesa en segundo plano; el resultado de cada persona queda en el registro.
Firma
Postal comprueba, en este orden, uno de estos métodos. El secreto es el webhook_secret (whsec_…) de la conexión:
| Método | Cabeceras | Cómo calcularla |
|---|---|---|
| Standard Webhooks (el mismo de los webhooks de Postal) | webhook-id, webhook-timestamp, webhook-signature (o svix-*) | v1, + Base64 del HMAC-SHA256 de {id}.{timestamp}.{corpo}, con el secreto como clave, sin whsec_ y decodificado de Base64. La fecha no puede tener más de 5 minutos de diferencia. Sirve cualquier biblioteca Standard Webhooks o Svix. |
| Hash del cuerpo | X-Postal-Signature | sha256= + hexadecimal del HMAC-SHA256 del cuerpo sin procesar, con el secreto completo, con el whsec_, como clave. Es el más sencillo para quien solo sabe calcular un hash. |
| Secreto como token (desactivado por defecto) | X-Postal-Token o ?token= | El propio secreto. Solo vale si la conexión tiene activado settings.inbound.allow_token (“Aceptar el secreto como token” en el panel), para herramientas que no saben firmar. Menos seguro: el secreto viaja en cada llamada y una URL con ?token= suele acabar en los registros. |
Si llegan los encabezados de Standard Webhooks, solo valen ellos; los otros métodos se usan cuando faltan. Ejemplo con el hash del cuerpo:
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"
}
El id es el de la entrada del registro. Firma el mismo texto que va en el cuerpo: si tu cliente reescribe el JSON después de firmar, la comprobación falla.
| Respuesta | Cuándo |
|---|---|
202 accepted | Recibido; se procesará. |
401 invalid_signature | Firma o token ausente o incorrecto, o fecha fuera de los 5 minutos. |
404 not_found | La conexión no existe (o el ID no es un UUID). |
409 connection_paused | La conexión está pausada o solo envía. |
413 payload_too_large | Cuerpo superior a 256 KB. |
422 validation_error | El cuerpo no es un objeto ni una lista JSON. |
#Formato del cuerpo
Sin mapeos, Postal lee el cuerpo en su formato: un objeto con una persona, o una lista de objetos. Los campos reconocidos:
| Campo | Qué es |
|---|---|
email | El correo electrónico. Es obligatorio: un registro sin correo (y sin vínculo ni external_id que lo encuentren) falla. |
external_id (o user_id) | El ID de la persona en tu app. |
crm_id | El ID del registro en tu sistema. Postal lo enlaza al contacto y lo devuelve en los envíos. |
first_name, last_name | Nombre y apellido. |
unsubscribed | Verdadero o falso (no vuelve a suscribir a quien ya canceló la suscripción). |
tags | Lista de etiquetas. |
properties | Objeto con las propiedades del contacto; las claves deben existir en el equipo. |
event, event_properties | El nombre de un evento que registrar en el contacto (letras, números y . _ : -, hasta 100 caracteres) y sus datos. Puede iniciar automatizaciones. |
{
"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
}
}
Si tu sistema envía el cuerpo de otra forma, usa los mapeos. Para webhooks con varias personas dentro de un objeto, settings.inbound.items_path apunta a la lista (por ejemplo $.data.items[*]); sin él, el cuerpo entero es un registro, o una lista de registros. Un webhook procesa hasta 500 personas. settings.inbound.event_field apunta al campo del registro cuyo valor es el nombre del evento; el registro completo va como datos del evento.
#Mapear los campos
Cuando el cuerpo de tu sistema no es el de Postal, mapea cada campo con settings.field_mappings y, si hay embudo, settings.stage. Las reglas (campos de Postal, rutas, sentido de cada mapeo, etapas) son las de Mapear campos y etapas. En el panel, pega un ejemplo de lo que envía el sistema: las rutas aparecen como sugerencia, y Ver el resultado del ejemplo muestra las personas que saldrían de él.
Por la API, POST /crm-connections/{id}/preview hace lo mismo sin guardar nada: envía el payload y, si quieres probar antes de guardar, las settings. Guardar el ejemplo en settings.sample_payload (hasta 32 KB) hace que GET /crm-connections/{id}/fields liste sus campos.
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": []
}
]
}
Aquí, lifecycle es una propiedad de contacto que ya existe. El resultado viene con la forma de un contacto de Postal; no se creó nada.
Guardar la configuración completa
Con el ejemplo funcionando, guarda todo de una vez. Recuerda que settings sustituye lo que había: envía el conjunto completo.
{
"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 a tu sistema
Con settings.outbound.url rellenada, Postal hace un POST firmado a esa URL por cada cambio de contacto y actividad de correo que la conexión suscribió en settings.outbound.events (el valor predeterminado y la lista completa están en Conexiones de CRM).
- La URL debe ser HTTPS y pública: las direcciones que resuelven a una red privada se rechazan, y las redirecciones no se siguen. El tiempo límite es de 10 segundos.
- Todo va en JSON, con tres campos:
type,created_atydata, como en los webhooks de Postal. Los encabezados incluyenUser-Agent: Postal-CRM/1.0y la firma. - Los campos de
datasiguen tus mapeos de envío; sin mapeos, el formato es el de Postal.
Personas
contact.created y contact.updated llevan en data a la persona. crm_id es el ID que tu sistema devolvió antes (o null). Una lista de propiedades vacía llega como [], no como {}.
{
"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"]
}
}
Actividad de correo
Los eventos email.* llevan a la persona en data.contact y, en data.email, el data del evento de Postal. contact.deleted también viene así: data.email trae el contacto como era antes de eliminarse, y crm_id viene 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"
}
}
}
}
Qué responder
Cualquier 2xx cuenta como entregado. Si la respuesta es JSON con id o crm_id (texto o número), Postal guarda ese ID como el registro de la persona en tu sistema y lo devuelve en crm_id en sus próximos cambios. Cualquier otra respuesta, o la falta de respuesta, es un fallo: Postal registra el estado y el comienzo del cuerpo (hasta 200 caracteres) y lo reintenta pasados 30 segundos, 2 minutos, 10 minutos, 30 minutos y 2 horas. Responde rápido y procesa en segundo plano. Como puede haber reenvíos, trata las entregas de forma idempotente, usando el webhook-id.
Comprobar la firma
Postal firma con el mismo secreto, al estilo de Standard Webhooks (webhook-id, webhook-timestamp, webhook-signature, y los mismos valores en svix-*): consulta Verificar la firma. También se envía X-Postal-Signature (sha256= + HMAC-SHA256 del cuerpo sin procesar, con el secreto completo como clave), más sencillo de comprobar:
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
Probar
Probar envío (POST /crm-connections/{id}/test) envía un evento postal.test a la URL, y la respuesta indica si tu sistema respondió y con qué estado. El cuerpo es {"type": "postal.test", "created_at": "…", "data": {"connection_id": "…"}}. Sin URL de envío, la prueba solo avisa de que la conexión recibe, y no envía. Enviar todos los contactos (POST /crm-connections/{id}/sync) envía cada contacto del equipo como contact.updated.
#Seguridad y buenas prácticas
- Guarda el secreto como una clave: solo en el servidor. Si se filtra, genera otro con
POST /crm-connections/{id}/webhook-secret/rotatey actualiza tu sistema; el anterior deja de valer al instante. - Prefiere la firma al token. Si necesitas el token, usa el encabezado
X-Postal-Tokenen lugar de?token=. - Usa
settings.skip_sources(por ejemplomy-app) para que Postal no devuelva a tu sistema los cambios que él mismo envió por la API. Los cambios de esta conexión (crm:<id>) nunca vuelven a ella. Consulta Sin bucles. - Prueba con pocos contactos antes de Enviar todos los contactos, y sigue el registro: muestra el motivo de cada fallo y permite reenviar.
- Límites y errores: consulta Conexiones de CRM.