Iniciar sesión Crear cuenta
Guías

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.

  1. En el panel, en Integraciones › CRM, elige Otro CRM (webhook) y ponle un nombre. O usa POST /crm-connections con provider: "generic".
  2. Configura tu sistema para enviar los cambios de las personas, firmados, a la webhook_url.
  3. Mapea los campos pegando un ejemplo de lo que envía tu sistema y comprobando el resultado antes de guardar.
  4. 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.
  5. 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étodoCabecerasCó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 cuerpoX-Postal-Signaturesha256= + 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:

Enviar una persona, firmada

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

RespuestaCuándo
202 acceptedRecibido; se procesará.
401 invalid_signatureFirma o token ausente o incorrecto, o fecha fuera de los 5 minutos.
404 not_foundLa conexión no existe (o el ID no es un UUID).
409 connection_pausedLa conexión está pausada o solo envía.
413 payload_too_largeCuerpo superior a 256 KB.
422 validation_errorEl 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:

CampoQué es
emailEl 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_idEl ID del registro en tu sistema. Postal lo enlaza al contacto y lo devuelve en los envíos.
first_name, last_nameNombre y apellido.
unsubscribedVerdadero o falso (no vuelve a suscribir a quien ya canceló la suscripción).
tagsLista de etiquetas.
propertiesObjeto con las propiedades del contacto; las claves deben existir en el equipo.
event, event_propertiesEl nombre de un evento que registrar en el contacto (letras, números y . _ : -, hasta 100 caracteres) y sus datos. Puede iniciar automatizaciones.
Cuerpo (una persona)
{
  "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.

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

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 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_at y data, como en los webhooks de Postal. Los encabezados incluyen User-Agent: Postal-CRM/1.0 y la firma.
  • Los campos de data siguen 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 {}.

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"]
  }
}

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.

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"
      }
    }
  }
}

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:

Recibir de 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

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/rotate y actualiza tu sistema; el anterior deja de valer al instante.
  • Prefiere la firma al token. Si necesitas el token, usa el encabezado X-Postal-Token en lugar de ?token=.
  • Usa settings.skip_sources (por ejemplo my-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.