Sign in Create account
Guides

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.

  1. In the dashboard, under Integrations › CRM, choose Other CRM (webhook) and give it a name. Or use POST /crm-connections with provider: "generic".
  2. Set up your system to send people's changes, signed, to the webhook_url.
  3. Map the fields by pasting a sample of what your system sends and checking the result before saving.
  4. For Postal to send contact changes and email activity, give the HTTPS URL of your system and the events you want.
  5. 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_…):

MethodHeadersHow 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 hashX-Postal-Signaturesha256= + 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:

Send a person, signed

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

ResponseWhen
202 acceptedReceived; it will be processed.
401 invalid_signatureSignature or token missing or wrong, or timestamp outside the 5 minutes.
404 not_foundThe connection does not exist (or the ID is not a UUID).
409 connection_pausedThe connection is paused or only sends.
413 payload_too_largeBody over 256 KB.
422 validation_errorThe 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:

FieldWhat it is
emailThe 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_idThe record ID in your system. Postal links it to the contact and returns it when sending.
first_name, last_nameFirst and last name.
unsubscribedTrue or false (it does not resubscribe someone who already unsubscribed).
tagsList of tags.
propertiesAn object with contact properties; the keys must exist in the team.
event, event_propertiesThe name of an event to record on the contact (letters, numbers and . _ : -, up to 100 characters) and its data. It can start automations.
Body (one person)
{
  "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.

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

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.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_at and data, as in Postal's webhooks. The headers carry User-Agent: Postal-CRM/1.0 and the signature.
  • The fields of data follow 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 {}.

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": "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.

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

Receive from Postal

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/rotate and 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-Token header instead of ?token=.
  • Use settings.skip_sources (for example my-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.