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

# Receive leads

> Subscribe an endpoint to receive Mailr landing-page leads, verify the HMAC signature, and avoid echo loops.

When someone scans the QR code on a Mailr postcard and fills out the landing-page form, that's a [**lead**](/results/leads). You
can receive leads two ways: subscribe an endpoint for instant delivery, or poll for recent leads.

**Required scope (both):** `webhooks:inbound`

<Note>
  Only landing-page leads (`source = landing_page`) are delivered or polled out. Leads you sync *into* Mailr via
  `POST /api/v1/tracking/lead` are never fanned back out — that would round-trip a lead straight into the CRM it
  came from.
</Note>

## Subscribe

`POST /api/v1/webhooks/subscribe` registers a public HTTPS URL for `lead.created` deliveries.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://app.getmailr.com/api/v1/webhooks/subscribe \
    -H "Authorization: Bearer $MAILR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{ "target_url": "https://hooks.example.com/mailr/leads" }'
  ```

  ```js Node.js theme={null}
  const res = await fetch("https://app.getmailr.com/api/v1/webhooks/subscribe", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.MAILR_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ target_url: "https://hooks.example.com/mailr/leads" }),
  });

  if (!res.ok) throw new Error(`Mailr API returned ${res.status}`);
  const { id, signing_secret } = await res.json();
  console.log(id, signing_secret); // store signing_secret — deliveries are HMAC-signed with it
  ```

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

  res = requests.post(
      "https://app.getmailr.com/api/v1/webhooks/subscribe",
      headers={"Authorization": "Bearer " + os.environ["MAILR_API_KEY"]},
      json={"target_url": "https://hooks.example.com/mailr/leads"},
  )

  res.raise_for_status()
  print(res.json())  # store signing_secret — deliveries are HMAC-signed with it
  ```

  ```php PHP theme={null}
  <?php
  $payload = ["target_url" => "https://hooks.example.com/mailr/leads"];

  $ch = curl_init("https://app.getmailr.com/api/v1/webhooks/subscribe");
  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 !== 200) {
      throw new RuntimeException("Mailr API returned $status: $body");
  }
  print_r(json_decode($body, true)); // store signing_secret — deliveries are HMAC-signed with it
  ```
</CodeGroup>

```json 200 OK theme={null}
{
  "id": "b7c8d9e0-0000-0000-0000-000000000010",
  "signing_secret": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c5b4a39281706f5e4d3c2b1a0"
}
```

<Warning>
  **Store the `signing_secret` — it is shown exactly once, here at creation.** Re-subscribing the same URL is
  idempotent and returns the existing subscription's `id` with `signing_secret: null` — it never rotates or
  re-discloses a live secret, so a re-subscribe can't silently break your verification and a compromised API
  key can't recover your active secrets. Lost the secret? [`POST /api/v1/webhooks/rotate`](#rotate-a-lost-secret)
  mints a new one and returns it once. A subscription created before signing shipped is delivered unsigned;
  delete and re-subscribe (or rotate) to upgrade it.
</Warning>

The URL is SSRF-guarded: public `https` only, and the hostname must resolve to public addresses — at
registration *and* again at delivery time. `event` defaults to `lead.created` (the only supported value).

To remove a subscription, `DELETE /api/v1/webhooks/subscribe?id=<id>` or `?target_url=<url>` (org-scoped).

## Rotate a lost secret

`POST /api/v1/webhooks/rotate` mints a replacement signing secret and returns it exactly once. Identify the
subscription by `id` or by `target_url` (plus optional `event`, default `lead.created`).

```bash curl theme={null}
curl -X POST https://app.getmailr.com/api/v1/webhooks/rotate \
  -H "Authorization: Bearer $MAILR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "id": "b7c8d9e0-0000-0000-0000-000000000010" }'
```

<Warning>
  Rotation takes effect immediately — deliveries sign with the new secret the moment it's issued. Update your
  receiver first, or tolerate a brief window where verification fails.
</Warning>

## The `lead.created` delivery

Mailr POSTs the canonical enriched lead to your endpoint. It's the same object shape the poll endpoint returns,
plus a `mailr_lead_id`:

```json theme={null}
{
  "id": "c1d2e3f4-0000-0000-0000-000000000020",
  "mailr_lead_id": "c1d2e3f4-0000-0000-0000-000000000020",
  "name": "Dana Reyes",
  "first_name": "Dana",
  "last_name": "Reyes",
  "email": "dana.reyes@example.com",
  "phone": "+15125550143",
  "address": "3134 River Valley Dr",
  "street": "3134 River Valley Dr",
  "city": "Austin",
  "state": "TX",
  "postal_code": "78701",
  "country": "United States",
  "full_address": "3134 River Valley Dr, Austin, TX 78701",
  "latitude": 30.27,
  "longitude": -97.75,
  "message": "Roof looks rough after the last storm — can you quote?",
  "campaign_id": "aa11bb22-0000-0000-0000-0000000000cc",
  "campaign_name": "Spring roof reactivation",
  "source": "Mailr",
  "custom_fields": [
    { "field_id": "issues", "field_label": "Issues", "type": "select", "value": ["Missing shingles"] }
  ],
  "custom_issues": "Missing shingles",
  "created_at": "2026-08-19T15:04:00Z"
}
```

Each `custom_fields` entry is also flattened to a top-level `custom_<field_id>` key (semicolon-joined) for
easy CRM mapping. Mailr does not inspect your response status — a delivery is considered attempted once the
request completes. Non-2xx responses are ignored; timeouts and blocked hosts are recorded in Mailr's server
logs only (not in your webhook log) and are **not retried**. Poll `GET /api/v1/integrations/leads` to reconcile
anything your endpoint dropped.

## Signature verification

A subscription with a `signing_secret` receives two headers, in the same shape Stripe and Slack use:

| Header              | Value                                                                 |
| ------------------- | --------------------------------------------------------------------- |
| `X-Mailr-Timestamp` | Unix **seconds**, as sent.                                            |
| `X-Mailr-Signature` | `sha256=<hex HMAC-SHA256>` over the string `"<timestamp>.<rawBody>"`. |

The signed string is `` `${timestamp}.${rawBody}` `` where `rawBody` is the **exact bytes** of the request body.
Verify by recomputing the HMAC over the raw body you received (do not re-serialize the parsed JSON — key order
or number formatting could drift), comparing in constant time, and rejecting timestamps that drift more than
**300 seconds** from now.

<CodeGroup>
  ```js Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const TOLERANCE_SECONDS = 300;

  // `rawBody` MUST be the exact string received, not JSON.parse'd and re-stringified.
  export function verifyMailrSignature(secret, rawBody, headers) {
    const ts = headers["x-mailr-timestamp"];
    const sig = headers["x-mailr-signature"];
    if (!/^\d{1,15}$/.test(String(ts ?? ""))) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > TOLERANCE_SECONDS) return false;

    const expected = "sha256=" +
      createHmac("sha256", secret).update(`${ts}.${rawBody}`, "utf8").digest("hex");

    const a = Buffer.from(expected);
    const b = Buffer.from(String(sig ?? "").toLowerCase());
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hmac, hashlib, time

  TOLERANCE_SECONDS = 300

  def verify_mailr_signature(secret: str, raw_body: bytes, headers: dict) -> bool:
      ts = headers.get("X-Mailr-Timestamp", "")
      sig = headers.get("X-Mailr-Signature", "").lower()
      if not (ts.isascii() and ts.isdigit()):
          return False
      if abs(int(time.time()) - int(ts)) > TOLERANCE_SECONDS:
          return False
      signed = f"{ts}.".encode() + raw_body
      expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(expected, sig)
  ```

  ```php PHP theme={null}
  <?php
  const TOLERANCE_SECONDS = 300;

  // $rawBody = file_get_contents("php://input");  // exact bytes — never re-encode the parsed JSON
  // $ts = $_SERVER["HTTP_X_MAILR_TIMESTAMP"] ?? ""; $sig = $_SERVER["HTTP_X_MAILR_SIGNATURE"] ?? "";
  function verify_mailr_signature(string $secret, string $rawBody, string $ts, string $sig): bool
  {
      if (!ctype_digit($ts)) {
          return false;
      }
      if (abs(time() - (int) $ts) > TOLERANCE_SECONDS) {
          return false;
      }

      $expected = "sha256=" . hash_hmac("sha256", $ts . "." . $rawBody, $secret);
      return hash_equals($expected, strtolower($sig));
  }
  ```
</CodeGroup>

## Avoiding echo loops

Because deliveries carry `mailr_lead_id`, a lead you receive here and then write into your CRM can be recognized
if your automation fires it back at `POST /api/v1/events`. Always copy `mailr_lead_id` into
`record.properties.mailr_lead_id` on any event derived from a Mailr lead — Mailr will skip campaign matching
(`reason: "loop_guard"`) rather than mailing an address it just sourced. See
[Send events → Loop guard](/api-reference/send-events#loop-guard).

## Poll instead

`GET /api/v1/integrations/leads?since=<ISO>` returns the 50 most recent landing-page leads, newest first, filtered
to `created_at` strictly after `since`. De-dupe by `id`.

```json 200 OK theme={null}
{ "leads": [ /* enriched lead objects, same shape as above (minus mailr_lead_id) */ ] }
```

<Note>
  A brand-new org with no real leads receives exactly one clearly flagged sample lead (`test: true`, fixed
  all-zero `id`) so a Zap's "Test trigger" step can map fields. It disappears once a real lead is captured.
</Note>
