CRM connection by webhook
Connect any CRM or system that sends and receives webhooks: receive people with a signature, map the fields with a sample and send contacts and email activity to your URL.
#Overview
The webhook connection links Postal to any CRM or system that can send and receive webhooks (a POST with JSON): a CRM with no native connection, an ERP, an automated spreadsheet, your own backend. Postal creates the URL and the secret; you say how the fields link. CRM connections covers the shared concepts: direction, mapping, log and retries.
- In the dashboard, under Integrations › CRM, choose Other CRM (webhook) and give it a name. Or use
POST /crm-connectionswithprovider: "generic". - Set up your system to send people's changes, signed, to the
webhook_url. - Map the fields by pasting a sample of what your system sends and checking the result before saving.
- For Postal to send contact changes and email activity, give the HTTPS URL of your system and the events you want.
- Use Test sending and follow everything in the log.
Depends on your plan
Like the other CRM connections, this one counts toward the marketing plan's crm_connections limit. See Limits.
#Receive from your system
Your system sends a POST with a JSON body (an object or a list) to the connection's webhook_url, https://api.postal.so/integrations/crm/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/webhook, with the signature. Postal answers 202 right away and processes in the background; the result for each person is in the log.
Signature
Postal checks one of these ways, in this order. The secret is the connection's webhook_secret (whsec_…):
| Method | Headers | How to compute it |
|---|---|---|
| Standard Webhooks (the same as Postal's webhooks) | webhook-id, webhook-timestamp, webhook-signature (or svix-*) | v1, + Base64 of the HMAC-SHA256 of {id}.{timestamp}.{body}, with the key being the secret without whsec_, Base64-decoded. The timestamp cannot be more than 5 minutes off. Any Standard Webhooks or Svix library works. |
| Body hash | X-Postal-Signature | sha256= + hex of the HMAC-SHA256 of the raw body, with the whole secret, including whsec_, as the key. It is the simplest for tools that can only compute a hash. |
| Secret as a token (off by default) | X-Postal-Token or ?token= | The secret itself. It only works if the connection has settings.inbound.allow_token on (“Accept the secret as a token” in the dashboard), for tools that cannot sign. Less secure: the secret travels with every call and a URL with ?token= tends to end up in logs. |
If the Standard Webhooks headers arrive, only they count; the other methods apply when they are missing. Example with the body hash:
cURL
# the connection secret (whsec_…), used as it is
SECRET="whsec_..."
URL="https://api.postal.so/integrations/crm/019a2b3c-4d5e-7f60-8a9b-0c1d2e3f4a5b/webhook"
BODY='{"email":"[email protected]","first_name":"Jane"}'
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; // the connection secret (whsec_…), used as it is
const body = JSON.stringify({ email: '[email protected]', first_name: 'Jane' }); // exactly the bytes you send
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"] # the connection secret (whsec_…), used as it is
body = json.dumps({"email": "[email protected]", "first_name": "Jane"}).encode() # exactly the bytes you send
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'); // the connection secret (whsec_…), used as it is
$body = json_encode(['email' => '[email protected]', 'first_name' => 'Jane']); // exactly the bytes you send
$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"
}
The id is the log entry's. Sign the same text that goes in the body: if your client rewrites the JSON after signing, the check fails.
| Response | When |
|---|---|
202 accepted | Received; it will be processed. |
401 invalid_signature | Signature or token missing or wrong, or timestamp outside the 5 minutes. |
404 not_found | The connection does not exist (or the ID is not a UUID). |
409 connection_paused | The connection is paused or only sends. |
413 payload_too_large | Body over 256 KB. |
422 validation_error | The body is not a JSON object or list. |
#Body format
With no mappings, Postal reads the body in its own format: an object with one person, or a list of objects. The fields it recognizes:
| Field | What it is |
|---|---|
email | The email. Required: a record with no email (and no link or external_id that finds it) fails. |
external_id (or user_id) | The person's ID in your app. |
crm_id | The record ID in your system. Postal links it to the contact and returns it when sending. |
first_name, last_name | First and last name. |
unsubscribed | True or false (it does not resubscribe someone who already unsubscribed). |
tags | List of tags. |
properties | An object with contact properties; the keys must exist in the team. |
event, event_properties | The name of an event to record on the contact (letters, numbers and . _ : -, up to 100 characters) and its data. It can start automations. |
{
"email": "[email protected]",
"first_name": "Jane",
"last_name": "Doe",
"external_id": "user_8421",
"crm_id": "51",
"tags": [
"lead",
"newsletter"
],
"properties": {
"plan": "pro"
},
"event": "deal.won",
"event_properties": {
"amount": 1200
}
}
If your system sends the body in another shape, use the mappings. For webhooks with several people inside an object, settings.inbound.items_path points at the list (for example $.data.items[*]); without it, the whole body is one record, or a list of records. A webhook processes up to 500 people. settings.inbound.event_field points at the record's field whose value is the event name; the whole record goes along as the event data.
#Map the fields
When your system's body is not Postal's, map each field with settings.field_mappings and, if there is a funnel, settings.stage. The rules (Postal fields, paths, each mapping's direction, stages) are those in Map fields and stages. In the dashboard, paste a sample of what the system sends: the paths show up as suggestions, and See the sample result shows the people that would come out of it.
Through the API, POST /crm-connections/{id}/preview does the same without saving anything: send the payload and, to try before saving, the settings. Storing the sample in settings.sample_payload (up to 32 KB) makes GET /crm-connections/{id}/fields list its fields.
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": "Jane"
},
"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: 'Jane',
},
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": "Jane",
},
"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' => 'Jane',
],
'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": "Jane"
},
{
"path": "$.data.items[0].deal.stage",
"sample": "Won"
},
{
"path": "$.data.items[0].labels",
"sample": "vip, quente"
}
],
"people": [
{
"email": "[email protected]",
"first_name": "Jane",
"properties": {
"lifecycle": "customer"
},
"tags": ["vip", "quente", "customer"],
"crm_id": "7",
"events": []
}
]
}
Here, lifecycle is a contact property that already exists. The result comes in the shape of a Postal contact; nothing was created.
Save the whole configuration
With the sample working, save everything at once. Remember that settings replaces what was there: send the whole set.
{
"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.example.com/postal",
"events": [
"contact.created",
"contact.updated",
"email.opened",
"email.clicked"
]
},
"skip_sources": [
"my-app"
]
}
}
#Send to your system
With settings.outbound.url set, Postal sends a signed POST to that URL for every contact change and email activity the connection subscribed to in settings.outbound.events (the default and the full list are in CRM connections).
- The URL must be HTTPS and public: addresses that resolve to a private network are refused, and redirects are not followed. The timeout is 10 seconds.
- Everything is JSON, with three fields:
type,created_atanddata, as in Postal's webhooks. The headers carryUser-Agent: Postal-CRM/1.0and the signature. - The fields of
datafollow your outbound mappings; with no mappings, the format is Postal's.
People
contact.created and contact.updated carry the person in data. crm_id is the ID your system returned earlier (or null). An empty list of properties arrives as [], not {}.
{
"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": "Jane",
"last_name": "Doe",
"unsubscribed": false,
"properties": {
"plan": "pro"
},
"tags": ["customer"]
}
}
Email activity
email.* events carry the person in data.contact and, in data.email, the data of the Postal event. contact.deleted also arrives this way: data.email carries the contact as it was before being deleted, and crm_id is 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": "Jane",
"last_name": "Doe",
"unsubscribed": false,
"properties": {
"plan": "pro"
},
"tags": ["customer"]
},
"email": {
"email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"created_at": "2026-10-26T14:01:00.000Z",
"from": "Acme Store <[email protected]>",
"to": ["[email protected]"],
"subject": "Your order #10482 is out for delivery",
"tags": {
"category": "shipping"
}
}
}
}
What to answer
Any 2xx counts as delivered. If the response is JSON with an id or crm_id (text or number), Postal stores that ID as the person's record in your system and sends it back in crm_id on their next changes. Any other response, or no response, is a failure: Postal logs the status and the start of the body (up to 200 characters) and tries again after 30 seconds, 2 minutes, 10 minutes, 30 minutes and 2 hours. Answer quickly and process in the background. Since there can be retries, handle deliveries idempotently, using the webhook-id.
Verify the signature
Postal signs with the same secret, the Standard Webhooks way (webhook-id, webhook-timestamp, webhook-signature, and the same values in svix-*): see Verify the signature. It also sends X-Postal-Signature (sha256= + HMAC-SHA256 of the raw body, with the whole secret as the key), which is simpler to check:
Node.js
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const secret = process.env.POSTAL_CRM_SECRET; // the connection secret (whsec_…)
app.post('/postal', express.raw({ type: 'application/json' }), (req, res) => {
// req.body: the raw body, before 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: the record or the activity
res.json({ id: '51' }); // optional: the person's ID in your system
});
Python
import hashlib
import hmac
import os
from flask import Flask, abort, jsonify, request
app = Flask(__name__)
SECRET = os.environ["POSTAL_CRM_SECRET"] # the connection secret (whsec_…)
@app.post("/postal")
def postal():
body = request.get_data() # bytes, exactly as received
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") # optional: the person's ID in your system
PHP
<?php
$secret = getenv('POSTAL_CRM_SECRET'); // the connection secret (whsec_…)
$body = file_get_contents('php://input'); // the raw body
$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']); // optional: the person's ID in your system
Test
Test sending (POST /crm-connections/{id}/test) sends a postal.test event to the URL, and the response says whether your system answered and with which status. The body is {"type": "postal.test", "created_at": "…", "data": {"connection_id": "…"}}. With no outbound URL, the test only says the connection receives, and sends nothing. Send every contact (POST /crm-connections/{id}/sync) sends every contact of the team as contact.updated.
#Security and best practices
- Keep the secret like a key: server-side only. If it leaks, generate another with
POST /crm-connections/{id}/webhook-secret/rotateand update your system; the previous one stops working right away. - Prefer the signature to the token. If you need the token, use the
X-Postal-Tokenheader instead of?token=. - Use
settings.skip_sources(for examplemy-app) so Postal does not hand back to your system the changes it sent through the API itself. This connection's own changes (crm:<id>) never go back to it. See No loops. - Test with a few contacts before Send every contact, and follow the log: it shows the reason for each failure and lets you send again.
- Limits and errors: see CRM connections.