POST /api/v1/events is the primary way to drive Mailr. Your CRM or automation stack pushes an event when
something happens — a job completes, a deal is won, a contact is created — and Mailr matches it against the
automations you’ve bound and, when one fires, enqueues a per-recipient Street View postcard campaign.
Required scope: events:write · Rate limit: 120 events / minute / org · Max body: 256 KB
The trigger-binding concept
There is no “connect” flow for the developer API — your key is the credential. The first valid event lazily creates a hiddencustom connection for your org. What turns an event into a postcard is a binding
you create in the app:
1
Send at least one live event
Live events are sampled — Mailr keeps a rolling sample of ~25 recent events per
event_type so your
event types show up in the app’s binding picker. Dry-run events write nothing and are not sampled.2
Create an automation and bind it
In the app, set an automation’s trigger to Custom API and bind it to an
event_type and optionally an
object_type. A binding with no object type accepts any; a binding that pins one only fires for that type.3
Events now fire that automation
Each matching event runs the same trigger core the 14 native CRM integrations use: CRM filters →
address check → idempotent enqueue → (for contacts) attribution.
matched_automations in the response counts bindings whose event_type/object_type matched — not
bindings that actually mailed. Always read outcomes[].reason for what really happened.The envelope
string
required
Your event name as
noun.verb, lowercase letters/digits/underscores, ≤64 chars (e.g. job.completed). Bound
in the app as custom.<event_type>.string
required
One of
job, deal, contact. A contact event that carries an address plus an email or phone is also run
through indirect attribution.string
required
Your idempotency key for this event, ≤255 chars. Replaying the same value is absorbed per bound automation.
string (ISO-8601)
When the event happened. Optional. May lead the server clock by at most 24 hours.
string
required
Your record’s id in your system, ≤128 chars.
object
A flat map of string → string, at most 100 keys. Values must be strings (numbers/booleans/nulls are
rejected, not coerced) of ≤2000 chars. Keys are lowercase snake_case, ≤64 chars, starting with a letter, and must be a reserved key or
cf_-prefixed.Reserved vs custom properties
These keys have defined meaning to Mailr:
Every other property MUST be prefixed
cf_. This is deliberate: a typo in a reserved key (zipcode,
clientemail) is rejected at the edge with a 422 rather than silently producing a wrongly-addressed
postcard.
The mailing address is composed from
street, city, state and zip. The combined value must be at most
500 characters — a longer composed address is rejected with a 422.Example
202 Accepted
Outcomes
Each candidate binding reports onereason:
reason
A campaign was enqueued (on a dry run, would be enqueued).
matched: true.reason
The automation’s CRM filters excluded this record.
reason
No
street plus city/zip.reason
This
dedup_id already enqueued for this automation. When every candidate is a duplicate, the top-level
duplicate is true.reason
mailr_lead_id matched one of your own leads — see below.reason
The enqueue itself threw. Logged to your webhook log; not silently reported as queued.
Loop guard
If a Mailr-delivered lead lands in your CRM and your automation fires an event straight back at us, you’d start a campaign off an address Mailr itself just sourced — forever. To prevent that, the outbound lead delivery carries amailr_lead_id. Echo it back in record.properties.mailr_lead_id on any event derived from a
Mailr lead:
reason: "loop_guard" and nothing
mails. A mailr_lead_id that is not one of your leads is ignored and the event takes the normal path.
Dry run
dry_run: true runs the full pipeline — binding match, CRM filters, address check, dedup lookup — and returns
exactly the outcomes a live call would, but writes nothing: no connection upsert, no sample, no enqueue, no
log. Use it to validate a binding before mailing. The response echoes dry_run: true so a log reader can
tell a rehearsal from a live call.
A dry run still consumes one unit of the 120/min quota — it does the same work as a live call.