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

# Ingest a CRM/automation event

> Push one event from your CRM/automation stack. Mailr matches it against
the automations bound to your `custom` connection and, when a binding
fires, enqueues a per-recipient postcard campaign through the same core
the native CRM syncs use.

The connection is created lazily on the first valid event — the key *is*
the credential, so there is no connect flow. Bind an automation to an
`event_type` + `object_type` in the app (Automations → trigger →
"Custom API"); Mailr keeps a rolling sample of recent events per type to
populate the binding picker.

**Reserved vs custom properties.** The reserved keys (`street`, `city`,
`state`, `zip`, `client_name`, `client_email`, `client_phone`, `amount`,
`status`, `mailr_lead_id`) have defined meaning. Every other property
MUST be prefixed `cf_` — a typo in a reserved key (`zipcode`,
`clientemail`) is rejected at the edge rather than silently producing a
wrongly-addressed postcard.

**Address requirement.** A campaign needs `street` AND at least one of
`city`/`zip`; anything less is reported per automation as
`reason: "no_address"` (no postcard). The composed mailing address
(`street` + `city` + `state` + `zip`, combined) must be at most 500
characters, or the event is rejected with a `422`.

**Loop guard.** If a Mailr-delivered lead landed in your CRM and your
automation fires an event back at us, stamp the lead id in
`mailr_lead_id`; matching is skipped and each candidate reports
`reason: "loop_guard"`. 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 binding match, filter, address
and dedup checks and returns exactly the outcomes a live call would — but
writes nothing (no connection upsert, no sample, no enqueue, no log). A
dry run still costs one unit of rate-limit quota.




## OpenAPI

````yaml /api-reference/openapi.yaml post /api/v1/events
openapi: 3.1.0
info:
  title: Mailr Developer API
  version: 1.0.0
  description: >
    The Mailr Developer API lets a contractor's own CRM or automation stack
    drive

    Mailr directly with a per-tenant bearer key — no vendor polling, no Zapier
    in

    the middle. You push **events** when a job or deal changes, Mailr matches
    them

    against the automations you configured in the app and mails per-recipient

    Street View postcards; you push **won deals** and **leads** for revenue

    attribution; you **subscribe** an endpoint to receive captured leads back

    (HMAC-signed); and you **stage past jobs** for a reactivation campaign.


    ## Authentication


    Every endpoint here authenticates with an organization-scoped API key

    presented as `Authorization: Bearer ak_…`. The key alone identifies the

    tenant — you never pass an `org_id`, connection id, or batch id in the body
    or

    query; anything you could name is derived from the key. Create keys in the

    Mailr app under your workspace name (bottom-left) -> Manage account ->

    API keys (workspace Owner only).


    ## Scopes


    Keys may declare scopes. Policy (v1): if a key declares **any** scopes, it
    must

    include the scope an operation requires; a key that declares **no** scopes
    is

    treated as unrestricted and passes every check. The scopes in use are

    `events:write`, `import:write`, and `webhooks:inbound`. Each operation below

    documents its required scope under `x-required-scope`.


    ## Base URL


    Production is `https://app.getmailr.com`. All paths below are relative to
    it.


    ## Versioning


    The public API is versioned under `/api/v1/*` — every endpoint documented

    here lives under it. The Mailr Zapier app targets `/api/v1/*`; build new

    integrations against the same prefix.


    ## Idempotency


    Write endpoints are idempotent on a caller-supplied key: `dedup_id` on
    events,

    `deal_id`/address on conversions and leads. Imports are **not**
    deduplicated:

    one request is one batch, and duplicate addresses within or across batches
    are

    staged independently.

    Replaying the same request is safe and is reported back to you (e.g.

    `duplicate: true`, `idempotent: true`, or `reason: "duplicate"`).


    ## Auth-path errors (all endpoints)


    Besides each endpoint's own statuses, every authenticated endpoint can

    return two RETRYABLE auth-path errors: `429` `{ "error": "rate_limited" }`

    when the pre-auth per-IP limit (60/min/IP) trips, and `503`

    `{ "error": "auth_unavailable" }` when the auth provider is temporarily

    unreachable (your key was NOT checked). Both carry `Retry-After`. Neither

    means your key is bad — only a `401` does.


    ## Delivery cadence


    There is **no reconciliation sweep**. Mailr does not reach back into your
    CRM

    to pull anything it missed — the caller is the sync. If a push fails or you

    suspect a gap, **replay your events**; idempotency makes replays free.
  contact:
    name: Mailr
    url: https://app.getmailr.com
servers:
  - url: https://app.getmailr.com
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Events
    description: Push CRM/automation events that trigger campaigns (Developer API v1).
  - name: Imports
    description: >-
      Stage a batch of past jobs/customers for a reactivation campaign
      (Developer API v1).
  - name: Leads
    description: Receive captured leads (subscribe) and poll recent leads.
  - name: Attribution
    description: >-
      Report won deals and synced leads so revenue attributes to the campaign
      that mailed the address.
  - name: Customers
    description: Register existing customers for address suppression.
  - name: Account
    description: Identify the key's organization and list its automations.
paths:
  /api/v1/events:
    post:
      tags:
        - Events
      summary: Ingest a CRM/automation event
      description: >
        Push one event from your CRM/automation stack. Mailr matches it against

        the automations bound to your `custom` connection and, when a binding

        fires, enqueues a per-recipient postcard campaign through the same core

        the native CRM syncs use.


        The connection is created lazily on the first valid event — the key *is*

        the credential, so there is no connect flow. Bind an automation to an

        `event_type` + `object_type` in the app (Automations → trigger →

        "Custom API"); Mailr keeps a rolling sample of recent events per type to

        populate the binding picker.


        **Reserved vs custom properties.** The reserved keys (`street`, `city`,

        `state`, `zip`, `client_name`, `client_email`, `client_phone`, `amount`,

        `status`, `mailr_lead_id`) have defined meaning. Every other property

        MUST be prefixed `cf_` — a typo in a reserved key (`zipcode`,

        `clientemail`) is rejected at the edge rather than silently producing a

        wrongly-addressed postcard.


        **Address requirement.** A campaign needs `street` AND at least one of

        `city`/`zip`; anything less is reported per automation as

        `reason: "no_address"` (no postcard). The composed mailing address

        (`street` + `city` + `state` + `zip`, combined) must be at most 500

        characters, or the event is rejected with a `422`.


        **Loop guard.** If a Mailr-delivered lead landed in your CRM and your

        automation fires an event back at us, stamp the lead id in

        `mailr_lead_id`; matching is skipped and each candidate reports

        `reason: "loop_guard"`. 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 binding match, filter,
        address

        and dedup checks and returns exactly the outcomes a live call would —
        but

        writes nothing (no connection upsert, no sample, no enqueue, no log). A

        dry run still costs one unit of rate-limit quota.
      operationId: postEvent
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventEnvelope'
            examples:
              jobCompleted:
                summary: A completed job triggers a campaign
                value:
                  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
                      client_phone: '+15125550143'
                      amount: '6400.00'
                      status: completed
                      cf_trade: roofing
              dryRun:
                summary: Validate a binding without mailing
                value:
                  event_type: job.completed
                  object_type: job
                  dedup_id: preview-1
                  dry_run: true
                  record:
                    id: '8842'
                    properties:
                      street: 3134 River Valley Dr
                      city: Austin
                      zip: '78701'
      responses:
        '202':
          description: >
            Accepted. The event was validated and processed.
            `matched_automations`

            counts bindings whose event_type/object_type matched — not bindings

            that actually mailed; read `outcomes[].reason` for the
            per-automation

            result. `duplicate` is true only when every candidate hit the
            campaign

            queue's dedup.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EventResult'
              examples:
                queued:
                  value:
                    received: true
                    duplicate: false
                    matched_automations: 1
                    outcomes:
                      - automation_id: a1b2c3d4-0000-0000-0000-000000000001
                        name: Post-job reactivation
                        matched: true
                        reason: queued
                noBinding:
                  summary: No automation bound to this event type
                  value:
                    received: true
                    duplicate: false
                    matched_automations: 0
                    outcomes: []
                dryRun:
                  value:
                    received: true
                    duplicate: false
                    dry_run: true
                    matched_automations: 1
                    outcomes:
                      - automation_id: a1b2c3d4-0000-0000-0000-000000000001
                        name: Post-job reactivation
                        matched: true
                        reason: queued
        '400':
          description: |
            The body could not be read or is not valid JSON. `error` is
            `invalid_payload` (unreadable body) or `invalid_json`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalidJson:
                  value:
                    error: invalid_json
                    detail: Body is not valid JSON.
        '401':
          description: >-
            Missing, invalid, revoked, or expired key. Body `{ "error":
            "unauthorized" }`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: unauthorized
        '403':
          description: >-
            Key resolves to no organization, or is missing the `events:write`
            scope. Body `{ "error": "unauthorized" }`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: unauthorized
        '413':
          description: Body exceeds 256 KB (declared or actual).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: payload_too_large
                detail: Body must be at most 262144 bytes.
        '422':
          description: |
            The envelope failed validation. `error` is always `invalid_payload`;
            `detail` names the offending field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                badEventType:
                  value:
                    error: invalid_payload
                    detail: >-
                      event_type must look like 'noun.verb' using lowercase
                      letters, digits and underscores (e.g. 'job.completed')
                unprefixed:
                  value:
                    error: invalid_payload
                    detail: >-
                      property 'zipcode' must be prefixed cf_ (custom field) or
                      be one of the reserved keys: street, city, state, zip,
                      client_name, client_email, client_phone, amount, status,
                      mailr_lead_id
        '429':
          description: More than 120 events in the current one-minute window for this org.
          headers:
            Retry-After:
              description: Seconds to wait before retrying.
              schema:
                type: string
                example: '60'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: rate_limited
                detail: At most 120 events per minute.
        '500':
          description: 'Unexpected server error. Body `{ "error": "internal_error" }`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: internal_error
        '503':
          description: >
            Automations are globally paused (platform kill switch). Retry later
            —

            do not drop the event.
          headers:
            Retry-After:
              schema:
                type: string
                example: '300'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              example:
                error: automations_disabled
                detail: Automations are temporarily disabled. Retry later.
components:
  schemas:
    EventEnvelope:
      type: object
      required:
        - event_type
        - object_type
        - dedup_id
        - record
      properties:
        event_type:
          type: string
          maxLength: 64
          pattern: ^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$
          description: >-
            Your event name as `noun.verb`, lowercase, ≤64 chars. Bound in the
            app as `custom.<event_type>`.
          example: job.completed
        object_type:
          type: string
          enum:
            - job
            - deal
            - contact
          description: >-
            What kind of record this event is about. A `contact` event with an
            address and email/phone is also run through indirect attribution.
        dedup_id:
          type: string
          maxLength: 255
          description: >-
            Your idempotency key for this event. Replaying the same value is
            absorbed per bound automation.
          example: job-8842-completed
        occurred_at:
          type:
            - string
            - 'null'
          format: date-time
          description: >-
            When the event happened (ISO-8601). Optional. May lead the server
            clock by at most 24h.
        dry_run:
          type: boolean
          default: false
          description: >-
            Validate and report outcomes without writing anything. Still
            consumes rate-limit quota.
        record:
          type: object
          required:
            - id
          properties:
            id:
              type: string
              maxLength: 128
              description: Your record's id in your system.
              example: '8842'
            properties:
              type: object
              maxProperties: 100
              description: >
                Flat string→string map. Values must be strings
                (numbers/booleans/

                nulls are rejected, not coerced), ≤2000 chars. Keys are
                lowercase

                snake_case matching `^[a-z][a-z0-9_]*$` (must start with a
                letter),

                ≤64 chars, and must be either a reserved key or `cf_`-prefixed.
              additionalProperties:
                type: string
                maxLength: 2000
              properties:
                street:
                  type: string
                  description: Reserved. Street line. Required (with city or zip) to mail.
                city:
                  type: string
                  description: Reserved.
                state:
                  type: string
                  description: Reserved.
                zip:
                  type: string
                  description: Reserved.
                client_name:
                  type: string
                  description: Reserved. Recipient name.
                client_email:
                  type: string
                  description: Reserved. Used for contact attribution.
                client_phone:
                  type: string
                  description: Reserved. Used for contact attribution.
                amount:
                  type: string
                  description: Reserved. Deal/job amount as a string.
                status:
                  type: string
                  description: Reserved. Stage/status, matchable in automation filters.
                mailr_lead_id:
                  type: string
                  description: >-
                    Reserved. Echo-loop guard — stamp a Mailr lead id here when
                    echoing our own lead back.
              example:
                street: 3134 River Valley Dr
                city: Austin
                state: TX
                zip: '78701'
                client_name: Dana Reyes
                amount: '6400.00'
                status: completed
                cf_trade: roofing
    EventResult:
      type: object
      required:
        - received
        - duplicate
        - matched_automations
        - outcomes
      properties:
        received:
          type: boolean
          const: true
        duplicate:
          type: boolean
          description: >-
            True only when there was at least one candidate binding and every
            one hit the campaign-queue dedup.
        dry_run:
          type: boolean
          description: Present and true only when the request set `dry_run`.
        matched_automations:
          type: integer
          description: >-
            Count of bindings whose event_type/object_type matched — not the
            count that mailed.
        outcomes:
          type: array
          items:
            $ref: '#/components/schemas/EventOutcome'
    Error:
      type: object
      description: >
        Error body. `error` is a stable machine code; `detail` (when present) is
        a

        human-readable explanation naming the offending field. Note two families

        of code style coexist across the API: snake_case machine codes on the v1

        and rate-limited endpoints (`invalid_payload`, `rate_limited`,

        `automations_disabled`) and human sentences on the legacy endpoints

        (`Unauthorized`, `No address found in payload`). Both are returned in
        the

        `error` field.
      required:
        - error
      properties:
        error:
          type: string
          example: invalid_payload
        detail:
          type: string
          example: event_type is required
    EventOutcome:
      type: object
      required:
        - automation_id
        - name
        - matched
        - reason
      properties:
        automation_id:
          type: string
        name:
          type:
            - string
            - 'null'
        matched:
          type: boolean
          description: >-
            True only when this event actually enqueued a campaign for this
            automation.
        reason:
          type: string
          enum:
            - queued
            - filtered_out
            - no_address
            - duplicate
            - loop_guard
            - failed
          description: |
            Per-automation outcome:
              - `queued` — a campaign was enqueued (dry run: would be enqueued).
              - `filtered_out` — the automation's CRM filters excluded this record.
              - `no_address` — no `street` plus `city`/`zip`.
              - `duplicate` — this dedup_id already enqueued for this automation.
              - `loop_guard` — `mailr_lead_id` matched one of your leads.
              - `failed` — the enqueue itself threw (logged; details in your webhook log).
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: ak_
      description: 'Organization-scoped Mailr API key. Header: `Authorization: Bearer ak_…`.'

````