> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getmailr.com/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Mailr is a direct-mail marketing platform for US home-services contractors. Each postcard is personalized with an AI-enhanced image of the recipient's own home and a unique QR code that leads to a personalized landing page. Campaigns can target a neighborhood the customer draws on a map, the homes around past jobs they upload or import, or fire automatically from their CRM. Mailr does not mail an uploaded recipient list. When answering questions, prefer the exact steps and UI labels from these docs, and direct users to app.getmailr.com to sign in. For anything involving account-specific data, billing disputes, or mail that appears lost, direct the user to support@getmailr.com.

# Send events

> Push CRM/automation events to POST /api/v1/events; Mailr matches them against your automations and mails per-recipient postcards.

`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 hidden `custom` connection for your org. What turns an event into a postcard is a *binding*
you create in the app:

<Steps>
  <Step title="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.
  </Step>

  <Step title="Create an automation and bind it">
    In the app, set an [automation](/campaigns/automations)'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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Note>
  `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.
</Note>

## The envelope

<ParamField body="event_type" type="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>`.
</ParamField>

<ParamField body="object_type" 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.
</ParamField>

<ParamField body="dedup_id" type="string" required>
  Your idempotency key for this event, ≤255 chars. Replaying the same value is absorbed per bound automation.
</ParamField>

<ParamField body="occurred_at" type="string (ISO-8601)">
  When the event happened. Optional. May lead the server clock by at most 24 hours.
</ParamField>

<ParamField body="dry_run" type="boolean" default="false">
  Validate and report outcomes without writing anything. See [Dry run](#dry-run).
</ParamField>

<ParamField body="record.id" type="string" required>
  Your record's id in your system, ≤128 chars.
</ParamField>

<ParamField body="record.properties" type="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.
</ParamField>

### Reserved vs custom properties

These keys have defined meaning to Mailr:

| Key                            | Meaning                                                                 |
| ------------------------------ | ----------------------------------------------------------------------- |
| `street`                       | Street line. Required (with `city` or `zip`) to mail.                   |
| `city`, `state`, `zip`         | Locality. `street` + (`city` or `zip`) is the minimum mailable address. |
| `client_name`                  | Recipient name printed on the card.                                     |
| `client_email`, `client_phone` | Used for contact attribution.                                           |
| `amount`                       | Deal/job value (as a string).                                           |
| `status`                       | Stage/status, matchable in automation filters.                          |
| `mailr_lead_id`                | Echo-loop guard — see [below](#loop-guard).                             |

**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.

<Warning>
  A campaign needs `street` **and** at least one of `city` / `zip`. Anything less is reported per automation as
  `reason: "no_address"` and nothing mails.
</Warning>

<Note>
  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`.
</Note>

## Example

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://app.getmailr.com/api/v1/events \
    -H "Authorization: Bearer $MAILR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "event_type": "job.completed",
      "object_type": "job",
      "dedup_id": "job-8842-completed",
      "occurred_at": "2026-08-19T15:04:00Z",
      "record": {
        "id": "8842",
        "properties": {
          "street": "3134 River Valley Dr",
          "city": "Austin",
          "state": "TX",
          "zip": "78701",
          "client_name": "Dana Reyes",
          "client_email": "dana.reyes@example.com",
          "amount": "6400.00",
          "status": "completed",
          "cf_trade": "roofing"
        }
      }
    }'
  ```

  ```js Node.js theme={null}
  const res = await fetch("https://app.getmailr.com/api/v1/events", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILR_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      event_type: "job.completed",
      object_type: "job",
      dedup_id: "job-8842-completed",
      occurred_at: "2026-08-19T15:04:00Z",
      record: {
        id: "8842",
        properties: {
          street: "3134 River Valley Dr",
          city: "Austin",
          state: "TX",
          zip: "78701",
          client_name: "Dana Reyes",
          client_email: "dana.reyes@example.com",
          amount: "6400.00",
          status: "completed",
          cf_trade: "roofing",
        },
      },
    }),
  });

  if (!res.ok) throw new Error(`Mailr API returned ${res.status}`);
  console.log(await res.json()); // read outcomes[].reason
  ```

  ```python Python theme={null}
  import os
  import requests

  payload = {
      "event_type": "job.completed",
      "object_type": "job",
      "dedup_id": "job-8842-completed",
      "occurred_at": "2026-08-19T15:04:00Z",
      "record": {
          "id": "8842",
          "properties": {
              "street": "3134 River Valley Dr",
              "city": "Austin",
              "state": "TX",
              "zip": "78701",
              "client_name": "Dana Reyes",
              "client_email": "dana.reyes@example.com",
              "amount": "6400.00",
              "status": "completed",
              "cf_trade": "roofing",
          },
      },
  }

  res = requests.post(
      "https://app.getmailr.com/api/v1/events",
      headers={"Authorization": "Bearer " + os.environ["MAILR_API_KEY"]},
      json=payload,
  )

  res.raise_for_status()
  print(res.json())  # read outcomes[].reason
  ```

  ```php PHP theme={null}
  <?php
  $payload = [
      "event_type" => "job.completed",
      "object_type" => "job",
      "dedup_id" => "job-8842-completed",
      "occurred_at" => "2026-08-19T15:04:00Z",
      "record" => [
          "id" => "8842",
          "properties" => [
              "street" => "3134 River Valley Dr",
              "city" => "Austin",
              "state" => "TX",
              "zip" => "78701",
              "client_name" => "Dana Reyes",
              "client_email" => "dana.reyes@example.com",
              "amount" => "6400.00",
              "status" => "completed",
              "cf_trade" => "roofing",
          ],
      ],
  ];

  $ch = curl_init("https://app.getmailr.com/api/v1/events");
  curl_setopt($ch, CURLOPT_POST, true);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  curl_setopt($ch, CURLOPT_HTTPHEADER, [
      "Authorization: Bearer " . getenv("MAILR_API_KEY"),
      "Content-Type: application/json",
  ]);
  curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));

  $body = curl_exec($ch);
  $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
  curl_close($ch);

  if ($status !== 202) {
      throw new RuntimeException("Mailr API returned $status: $body");
  }
  print_r(json_decode($body, true)); // read outcomes[].reason
  ```
</CodeGroup>

```json 202 Accepted theme={null}
{
  "received": true,
  "duplicate": false,
  "matched_automations": 1,
  "outcomes": [
    {
      "automation_id": "a1b2c3d4-0000-0000-0000-000000000001",
      "name": "Post-job reactivation",
      "matched": true,
      "reason": "queued"
    }
  ]
}
```

## Outcomes

Each candidate binding reports one `reason`:

<ResponseField name="queued" type="reason">
  A campaign was enqueued (on a dry run, *would be* enqueued). `matched: true`.
</ResponseField>

<ResponseField name="filtered_out" type="reason">
  The automation's CRM filters excluded this record.
</ResponseField>

<ResponseField name="no_address" type="reason">
  No `street` plus `city`/`zip`.
</ResponseField>

<ResponseField name="duplicate" type="reason">
  This `dedup_id` already enqueued for this automation. When *every* candidate is a duplicate, the top-level
  `duplicate` is `true`.
</ResponseField>

<ResponseField name="loop_guard" type="reason">
  `mailr_lead_id` matched one of your own leads — see below.
</ResponseField>

<ResponseField name="failed" type="reason">
  The enqueue itself threw. Logged to your webhook log; not silently reported as queued.
</ResponseField>

<h2 id="loop-guard">
  Loop guard
</h2>

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 a `mailr_lead_id`. **Echo it back** in `record.properties.mailr_lead_id` on any event derived from a
Mailr lead:

```json theme={null}
{ "record": { "properties": { "mailr_lead_id": "c1d2e3f4-0000-0000-0000-000000000020", "...": "..." } } }
```

When Mailr recognizes one of your own lead ids, every candidate reports `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.

<h2 id="dry-run">
  Dry run
</h2>

`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.

<Note>
  A dry run still consumes one unit of the 120/min quota — it does the same work as a live call.
</Note>

## Status codes

| Status | `error`                            | Meaning                                                       |
| ------ | ---------------------------------- | ------------------------------------------------------------- |
| `202`  | —                                  | Accepted (read `outcomes`).                                   |
| `400`  | `invalid_json` / `invalid_payload` | Unreadable body or invalid JSON.                              |
| `401`  | `unauthorized`                     | Missing/invalid/revoked/expired key.                          |
| `403`  | `unauthorized`                     | No org resolved, or missing `events:write`.                   |
| `413`  | `payload_too_large`                | Body over 256 KB.                                             |
| `422`  | `invalid_payload`                  | Envelope validation failed; `detail` names the field.         |
| `429`  | `rate_limited`                     | Over 120/min. `Retry-After: 60`.                              |
| `503`  | `automations_disabled`             | Platform kill switch. `Retry-After: 300` — retry, don't drop. |
| `500`  | `internal_error`                   | Unexpected server error.                                      |
