Syncing delivered and opened status into Salesforce: a capability checklist
Five things an email API has to expose before CRM sync is buildable: a durable id, a typed event feed, an idempotency key, enumeration, and open tracking.
Before you compare vendors, write down what the sync actually consumes: a message identifier you can stamp on a Salesforce record, an event feed carrying type, timestamp and recipient, a value stable enough to use as an External Id, some way to enumerate which messages need checking, and open tracking if opens are genuinely part of the requirement. Infrai gives you the first four straightforwardly. The fifth has a price, and that is the honest headline of this page.
Rank them in that order. A provider with beautiful open analytics and no stable per-message id will cost you a month of reconciliation work; the reverse merely costs you a column.
The checklist, scored
| What the sync needs | Why Salesforce needs it | Infrai | SendGrid | Postmark |
|---|---|---|---|---|
| Durable per-message id | the join key on the Task or custom object | message_id, returned at send | X-Message-Id | MessageID |
| Typed event feed | one row per state change | GET /v1/email/event/list | Event Webhook | Messages API + webhook |
| Push delivery | sub-second CRM freshness | no — polling only | yes | yes |
| Enumerate messages | backfill and gap repair | GET /v1/email/list | Activity API | Messages API |
| Open tracking | the “opened” column exists at all | needs a verified domain, which is Pro | included | included |
| Cost per message | the thing finance asks about | metered per email | tiered plan | tiered plan |
Two of those rows are decisive if opens are non-negotiable and you’re on a standard account. Say so to your stakeholders early rather than discovering it during a sprint.
The identifier, at the moment of send
Every send hands back the handle. Write it into the same transaction that creates the Salesforce record — not into a log line.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS -X POST "https://api.infrai.cc/v1/email/send" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"to":"lead@example.com","subject":"Your quote from Northwind","html":"<p>The quote you asked for is attached to your account.</p>"}'
{
"ok": true,
"data": {
"message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7",
"mode": "default_vendor",
"from_used": "noreply+a1f9@send.infrai.cc",
"accepted_recipients": ["lead@example.com"],
"suppressed_recipients": []
}
}
That value goes in your custom Infrai_Message_Id__c field. Everything downstream is a fan-out from there.
The event feed is per message, not a stream
This is the shape that decides your worker’s architecture, so it’s worth being blunt about: message_id is a required query parameter. There is no route that tails every event on the account, so you fan out one request per message you care about rather than consuming a firehose.
curl -sS "https://api.infrai.cc/v1/email/event/list?message_id=msg_Hd029dIYk7I6cdlWLbiRQal7" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "type": "sent", "at": "2026-07-26T01:11:42.719655Z", "recipient": "lead@example.com", "message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7", "meta": { "vendor_message_id": "2f86043b-8177-4832-a3bf-47361f2acd89" } },
{ "type": "queued", "at": "2026-07-26T01:11:42.673284Z", "recipient": "lead@example.com", "message_id": "msg_Hd029dIYk7I6cdlWLbiRQal7", "meta": { "vendor": "resend" } }
],
"next_cursor": null,
"count": 2
}
}
Three fields matter to the sync and one is a trap. type maps to your picklist, at is an ISO-8601 instant, recipient disambiguates a multi-address send.
The trap is ordering.
Events come back newest first, so sort ascending before you replay them into a CRM that expects a chronology — a Task feed that shows sent above queued is the kind of thing an account executive notices immediately and nobody wants to explain twice.
Enumerating what to fan out over
GET /v1/email/list is how a backfill or a gap-repair job finds work. One caveat we measured on 2026-07-26: limit is honoured, but on an account holding 29 messages next_cursor came back null and a cursor parameter had no effect, so treat the list as a bounded recent window rather than a guaranteed cursor walk. Drive your incremental sync from your own outbox table and use this route to find drift.
curl -sS "https://api.infrai.cc/v1/email/list?limit=3" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "message_id": "msg_G9CJD8olw9Om4aQaTC6p3Gm2", "state": "sent", "channel": "email", "to": "lead@example.com", "vendor": "resend", "created_at": 1785028312.2295365 }
],
"next_cursor": null,
"count": 3
}
}
One inconsistency to code around rather than be surprised by: GET /v1/email/domain/list puts its payload under data.records, while email/list, event/list and suppression/list all use data.items. It’s a small drawback and a one-line accessor, but a shared “unwrap the list” helper written against one of them will silently return nothing on the other.
The External Id that makes replays free
Salesforce upserts by External Id, which turns idempotency into a naming problem. Compose the key from values the API guarantees are stable — the message id, the event type and the event instant:
def external_id(event: dict) -> str:
return f"{event['message_id']}:{event['type']}:{event['at']}"
Re-running yesterday’s sync then updates the same rows instead of manufacturing duplicate activity history, which matters a great deal here because polling means you will re-read the same events many times.
The worker, in Python 3
Reads your outbox, fans out to the event feed with bounded concurrency, and emits ready-to-upsert records. The Salesforce call is left as one clearly marked function so you can drop in your own authenticated session.
#!/usr/bin/env python3
"""sync_email_events.py — poll Infrai email events, upsert into Salesforce."""
import os
import sys
import json
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
import requests
API = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
sys.exit("INFRAI_API_KEY is not set")
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})
TERMINAL = {"delivered", "bounced", "complained", "failed"}
def events_for(message_id: str) -> list[dict]:
url = f"{API}/v1/email/event/list?message_id={urllib.parse.quote(message_id)}"
resp = SESSION.get(url, timeout=20)
if resp.status_code == 404:
return []
resp.raise_for_status()
payload = resp.json()
if not payload.get("ok"):
raise RuntimeError(payload.get("error", {}).get("code", "UNKNOWN"))
return sorted(payload["data"]["items"], key=lambda e: e["at"])
def external_id(event: dict) -> str:
return f"{event['message_id']}:{event['type']}:{event['at']}"
def to_salesforce_record(event: dict) -> dict:
return {
"External_Id__c": external_id(event),
"Infrai_Message_Id__c": event["message_id"],
"Recipient__c": event["recipient"],
"Event_Type__c": event["type"],
"Occurred_At__c": event["at"],
}
def upsert(records: list[dict]) -> None:
"""Replace with your authenticated Salesforce composite call."""
for record in records:
print(json.dumps(record))
def open_message_ids() -> list[str]:
"""Replace with a query against your own outbox table."""
return [m.strip() for m in sys.argv[1:] if m.strip()]
def main() -> int:
pending = open_message_ids()
if not pending:
print("nothing to sync", file=sys.stderr)
return 0
batch: list[dict] = []
with ThreadPoolExecutor(max_workers=8) as pool:
for message_id, events in zip(pending, pool.map(events_for, pending)):
if not events:
print(f"no events yet for {message_id}", file=sys.stderr)
continue
batch.extend(to_salesforce_record(e) for e in events)
if events[-1]["type"] in TERMINAL:
print(f"{message_id} reached {events[-1]['type']}", file=sys.stderr)
upsert(batch)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Eight workers is a deliberate ceiling. Reads are free, but a thread per pending message turns a 5,000-row backlog into 5,000 simultaneous sockets, and the pool keeps the request rate proportional to your worker count instead of your backlog — which also means the job’s cost profile doesn’t change when marketing sends a campaign, since you’re paying for sends and not for the polling that follows them. The other habit worth copying is treating an empty event list and a 404 as different outcomes: no events yet means the message is young and you should look again, while EMAIL_NOT_FOUND means the id is wrong and no amount of patience will fix it, so the row should be marked terminal and taken out of the rotation rather than retried forever.
Open tracking is the row with a price
Opens are recorded by a tracking pixel served from a CNAME on your own sending domain. That domain has to be registered through POST /v1/email/domain/verify, and on a standard account that call answers HTTP 402:
{
"ok": false,
"error": {
"code": "PRO_REQUIRED",
"http_status": 402,
"message": "custom sender domains are Pro-only; standard accounts have 0 custom sender domains",
"retryable": false
}
}
So on a standard plan the realistic feed is queued, sent, delivered, bounced and complained — delivery truth, not engagement. If “opened” is a hard requirement in the CRM spec and upgrading isn’t on the table, stick with SendGrid or Postmark for this workload; both include open and click tracking with no domain gate, and both push events rather than making you poll.
What the sync costs to run
Every read here is free and rate-limited, which is the reason a fan-out design is affordable at all — a 10,000-message backfill is 10,000 free calls. Only the send is metered: $0.000115 per email, verified 2026-07-26 and marked approximate, with $2 of free credit on a new account. Restated per thousand that’s about $0.12, which is a derived figure, not a published unit. Rates move down over time, so read the live one:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| python3 -c 'import json,sys
for c in json.load(sys.stdin)["capabilities"]:
if c["id"] in ("email.send", "email.event.list", "email.list"):
print(c["id"], c["billing"].get("price_usd", "free"), c["billing"].get("unit"))'
The reason to run CRM-bound mail here anyway is that the poller, its schedule, its error capture and the per-tenant attribution of the sends are all on the same key — one bill, one usage view, no second vendor to onboard when the sync needs a queue.