Postmark is a transactional email service focused on speed and delivery reliability: average delivery time is 2-3 seconds, delivery rate 98%+. Unlike Mailchimp or MailerLite (bulk email), Postmark specializes in individual transactional emails: confirmations, notifications, invoices, document links. For Kommo, the integration solves specific problems: automatically send an email when a pipeline stage changes and receive incoming replies back into Kommo.
Postmark API uses the X-Postmark-Server-Token: {SERVER_TOKEN} header. Sending an email: POST /email. Templates: POST /email/withTemplate. Inbound (incoming email): Postmark parses incoming emails and sends a webhook to your endpoint.
Postmark Server is an isolated environment for one type of email. Transactional emails and bulk broadcasts must be on separate servers - this is a fundamental Postmark rule to protect domain reputation.
Architecture for Use with Kommo
Kommo: deal -> stage "Contract Signed"
-> Kommo webhook -> Your server
Your server
-> Kommo API: get email, name, deal details
-> Postmark: POST /email/withTemplate
{to, template_alias: "deal-won-confirmation",
template_model: {name, deal_id, amount}}
-> Kommo: note "Confirmation email sent"
Client replies to the email
-> Postmark Inbound webhook -> Your server
-> Kommo: note with reply text
Implementation: Outgoing Email
import requests, os
from flask import Flask, request, jsonify
app = Flask(__name__)
POSTMARK_TOKEN = os.environ["POSTMARK_SERVER_TOKEN"]
POSTMARK_BASE = "https://api.postmarkapp.com"
POSTMARK_HDR = {
"X-Postmark-Server-Token": POSTMARK_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
}
FROM_EMAIL = os.environ["POSTMARK_FROM_EMAIL"] # verified sender
KOMMO_SUBDOMAIN = os.environ["KOMMO_SUBDOMAIN"]
KOMMO_TOKEN = os.environ["KOMMO_ACCESS_TOKEN"]
STAGE_EMAIL_MAP = {
int(os.environ.get("STAGE_PROPOSAL", "0")): ("proposal-sent", "Коммерческое предложение отправлено"),
int(os.environ.get("STAGE_SIGNED", "0")): ("deal-won-confirmation", "Подтверждение сделки отправлено"),
int(os.environ.get("STAGE_INVOICE", "0")): ("invoice-notification", "Счёт отправлен на email"),
}
KOMMO_BASE = f"https://{KOMMO_SUBDOMAIN}.kommo.com/api/v4"
KOMMO_HDR = {"Authorization": f"Bearer {KOMMO_TOKEN}", "Content-Type": "application/json"}
def get_lead_email_details(lead_id: int) -> tuple[str, str, dict]:
r = requests.get(
f"{KOMMO_BASE}/leads/{lead_id}",
headers=KOMMO_HDR,
params={"with": "contacts,custom_fields_values"},
)
lead = r.json()
contacts = lead.get("_embedded", {}).get("contacts", [])
email = name = ""
if contacts:
rc = requests.get(
f"{KOMMO_BASE}/contacts/{contacts[0]['id']}",
headers=KOMMO_HDR,
params={"with": "custom_fields_values"},
)
c = rc.json()
name = c.get("name", "")
for cf in c.get("custom_fields_values", []) or []:
if cf.get("field_code") == "EMAIL":
vals = cf.get("values", [])
if vals:
email = vals[0].get("value", "")
break
model = {
"client_name": name,
"deal_id": str(lead_id),
"deal_name": lead.get("name", ""),
"amount": str(lead.get("price") or 0),
}
return email, name, model
def send_template_email(to: str, template_alias: str, model: dict, tag: str) -> str:
r = requests.post(
f"{POSTMARK_BASE}/email/withTemplate",
headers=POSTMARK_HDR,
json={
"From": FROM_EMAIL,
"To": to,
"TemplateAlias": template_alias,
"TemplateModel": model,
"MessageStream": "outbound",
"Tag": tag,
"ReplyTo": FROM_EMAIL,
"Metadata": {
"kommo_lead_id": str(model.get("deal_id", "")),
},
},
)
r.raise_for_status()
return r.json().get("MessageID", "")
def add_note(lead_id: int, text: str):
requests.post(
f"{KOMMO_BASE}/notes",
headers=KOMMO_HDR,
json=[{
"entity_id": lead_id,
"entity_type": "leads",
"note_type": "common",
"params": {"text": text},
}],
)
@app.route("/webhooks/kommo", methods=["POST"])
def kommo_webhook():
data = request.json or {}
for lead_data in data.get("leads", {}).get("status", []):
lead_id = lead_data.get("id")
new_status = lead_data.get("status_id")
stage_info = STAGE_EMAIL_MAP.get(new_status)
if not stage_info:
continue
template_alias, note_text = stage_info
email, name, model = get_lead_email_details(lead_id)
if not email:
continue
msg_id = send_template_email(email, template_alias, model, tag=template_alias)
add_note(lead_id, f"Postmark: {note_text}. MessageID: {msg_id}")
return jsonify({"status": "ok"}), 200
Implementation: Postmark Inbound (Incoming Replies)
@app.route("/webhooks/postmark/inbound", methods=["POST"])
def postmark_inbound():
event = request.json or {}
from_email = event.get("FromFull", {}).get("Email", "")
subject = event.get("Subject", "")
text_body = event.get("TextBody", "") or event.get("StrippedTextReply", "")
html_body = event.get("HtmlBody", "")
# Extract kommo_lead_id from headers or subject
headers = {h.get("Name", ""): h.get("Value", "") for h in event.get("Headers", [])}
lead_id = headers.get("X-Kommo-Lead-Id", "")
# Fallback: find by sender email
if not lead_id:
lead_id = find_lead_by_email(from_email)
lead_id = str(lead_id) if lead_id else ""
if not lead_id:
return jsonify({"status": "no_lead_id"}), 200
body_preview = (text_body or html_body)[:500].strip()
add_note(
int(lead_id),
f"Incoming email from {from_email}.\nSubject: {subject}\n\n{body_preview}",
)
return jsonify({"status": "ok"}), 200
def find_lead_by_email(email: str) -> int | None:
r = requests.get(
f"{KOMMO_BASE}/contacts",
headers=KOMMO_HDR,
params={"query": email, "limit": 5},
)
contacts = r.json().get("_embedded", {}).get("contacts", []) or []
if not contacts:
return None
r2 = requests.get(
f"{KOMMO_BASE}/leads",
headers=KOMMO_HDR,
params={"filter[contact_id]": contacts[0]["id"], "limit": 1},
)
leads = r2.json().get("_embedded", {}).get("leads", []) or []
return leads[0]["id"] if leads else None
Postmark Templates
Create templates in Postmark Dashboard -> Templates. Example template deal-won-confirmation:
Subject: Your deal is confirmed, {{client_name}}!
Dear {{client_name}},
We are pleased to inform you that deal {{deal_name}} (ID: {{deal_id}}) has been finalized.
Amount: ${{amount}}.
Best regards,
[Company] Team
Template variables {{variable}} are populated from TemplateModel in the API request.
Postmark Inbound: Setup
Postmark Dashboard -> Servers -> [Server] -> Settings -> Inbound:
- Inbound domain:
inbound.yourdomain.com(or use the Postmark-provided address) - Webhook URL:
https://your-server.com/webhooks/postmark/inbound
MX record: inbound.yourdomain.com -> mx.postmarkapp.com (or configure email forwarding if you don’t have your own domain).
To pass lead_id into the client’s reply: add a custom header X-Kommo-Lead-Id when sending:
# In send_template_email - add Headers
json={
...
"Headers": [
{"Name": "X-Kommo-Lead-Id", "Value": str(model.get("deal_id", ""))},
],
}
When the client replies, most email clients preserve the header - Postmark Inbound will parse it.
Postmark vs SendGrid vs AWS SES
| Postmark | SendGrid | AWS SES | |
|---|---|---|---|
| Delivery (transactional) | 98%+ | 96% | 95% |
| Speed | 2-3 sec | 5-10 sec | 3-5 sec |
| Price (10k emails) | $15 | $19.95 | $1 |
| Inbound parsing | Yes | Yes (expensive) | No |
| Templates | Yes | Yes | No |
Postmark costs more than SES, but delivery rate and speed are best in class. For transactional emails where guaranteed delivery matters, Postmark is worth it.
Who This Is For
B2B SaaS and service companies where every transactional email is critical: deal confirmations, invoices, contract links. Especially relevant for companies that have already encountered spam filtering via shared email (Gmail SMTP, G Suite SMTP) - Postmark solves the domain reputation problem.
For bulk email marketing, see Kommo + MailerLite.
Frequently Asked Questions
Do I need to verify my domain in Postmark?
Absolutely. Without domain verification (DKIM + SPF), emails will go out labeled “via postmarkapp.com” and have low deliverability. Verification: Postmark Dashboard -> Sender Signatures -> Add Domain -> add DNS records. Takes 5-10 minutes.
How does Postmark handle bounces?
Postmark automatically handles hard and soft bounces. On a hard bounce (non-existent email address), Postmark deactivates the address and stops sending to it. The bounce webhook - POST /webhooks event of type bounce - can be used to update the contact in Kommo (add tag “email_invalid”).
How do Message Streams work in Postmark?
Message Streams separate transactional and broadcast (marketing) emails within a single server. MessageStream: "outbound" - transactional. "broadcast" - marketing. Different streams = different IPs = different reputation. This is critical: one failed broadcast campaign does not affect transactional email delivery.
Summary
Kommo + Postmark - transactional email from your pipeline:
X-Postmark-Server-Tokenheader,POST /email/withTemplatewith TemplateAliaskommo_lead_idMetadata for reverse correlation- Inbound webhook: incoming client replies -> note in Kommo
X-Kommo-Lead-Idheader in outgoing emails for reliable reply correlation- Message Streams: transactional and marketing emails on separate streams
If you need Postmark integration with Kommo or help setting up transactional emails from your pipeline - describe your task to the Exceltic.dev team.