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

# Webhooks

> Receive `report.completed`, `report.failed`, and `batch.completed` events with HMAC-SHA256-signed payloads.

Polling adds latency and burns rate-limit headroom. Webhooks let AcreLens push completed reports to your endpoint the moment they finish.

## Configure your endpoint

You can set webhook URLs at two levels:

* **Per-customer default** — set in the [dashboard](https://acrelens.com/dashboard/manage/webhooks) under **Manage → Webhooks**. Applies to every report unless overridden.
* **Per-request override** — pass `webhook_url` in the request body of `POST /v1/analyze` or `POST /v1/batch`. Useful for routing different report types to different services.

```json theme={null}
{
  "address": "123 Cabin Rd, Taos, NM",
  "state": "NM",
  "mode": "off_grid",
  "webhook_url": "https://myapp.com/webhooks/acrelens"
}
```

## Events

| Event | When | Payload |
| - | - | - |
| `report.completed` | A `/v1/analyze` report finishes successfully | Full report body (see below) |
| `report.failed` | A report hits an unrecoverable error | `report_id`, `status: "failed"`, `failed_reason` |
| `batch.completed` | All items in a `/v1/batch` request have finished (success or fail) | Batch summary with per-item results |

## Headers

Every delivery includes these:

```http theme={null}
Content-Type: application/json
X-AcreLens-Event: report.completed
X-AcreLens-Signature: t=1714147200,v1=4f8b3c2e9a1d...
X-AcreLens-Delivery: 7f8a9e2b-4c3d-4e5f-6a7b-8c9d0e1f2a3b
```

| Header | Purpose |
| - | - |
| `X-AcreLens-Event` | Event type — switch on this in your handler |
| `X-AcreLens-Signature` | HMAC-SHA256 signature in `t=<unix>,v1=<hex>` format |
| `X-AcreLens-Delivery` | Unique ID per delivery attempt — useful for dedup if AcreLens retries |

## Payload examples

### `report.completed`

Mirrors the `GET /v1/reports/{id}` response when status is `completed` —
same fields, same shape:

```json theme={null}
{
  "report_id": "rpt_01HY2Z...",
  "status": "completed",
  "mode": "off_grid",
  "property": {
    "address": "...", "state": "NM", "county": "Taos",
    "lat": 36.4, "lng": -105.5, "acreage": 5.2,
    "asking_price": 65000
  },
  "scores": { "overall": 78, "sub_scores": { "solar": 92, "water": 64, "septic": 71, "building_codes": 80, "access": 83 } },
  "confidence": { "solar": "high", "water": "medium", "septic": "medium", "building_codes": "high", "access": "high" },
  "summary": "Strong off-grid fundamentals...",
  "details": {
    "solar": { "score": 92, "summary": "...", "details": ["..."] },
    "water": { "score": 64, "summary": "...", "details": ["..."] },
    "buildability": {
      "score": 71, "summary": "...", "details": ["..."],
      "rv_living_allowed": "conditional",
      "composting_toilet_allowed": "conditional",
      "permit_difficulty": "moderate"
    },
    "access": { "score": 83, "summary": "...", "details": ["..."] }
  },
  "considerations": ["Verify county regulations...", "..."],
  "estimated_costs": {
    "solar": { "low": 18000, "high": 30000, "notes": "..." },
    "well": { "low": 7500, "high": 25000, "notes": "..." },
    "septic": { "low": 5000, "high": 22000, "notes": "..." },
    "road": { "low": 0, "high": 3000, "notes": "..." },
    "permits": { "low": 1000, "high": 3000, "notes": "..." },
    "total_low": 31500, "total_high": 83000
  },
  "sources": [...],
  "metadata": { "your_internal_id": "abc-123" },
  "generated_at": "2026-04-28T22:00:00.000Z"
}
```

See the [endpoint reference](/reference/endpoint-reference) for the full
response schema with field descriptions.

### `report.failed`

```json theme={null}
{
  "report_id": "rpt_01HY2Z...",
  "status": "failed",
  "mode": "off_grid",
  "failed_reason": "Geocoding lookup failed for address."
}
```

### `batch.completed`

```json theme={null}
{
  "batch_id": "bat_01HY2Z...",
  "status": "completed",
  "items_count": 3,
  "completed_count": 2,
  "failed_count": 1,
  "items": [
    { "report_id": "rpt_a...", "status": "completed", /* full report body */ },
    { "report_id": "rpt_b...", "status": "completed", /* full report body */ },
    { "report_id": "rpt_c...", "status": "failed", "failed_reason": "..." }
  ]
}
```

## Signature verification

The `X-AcreLens-Signature` header has the form:

```
t=<unix_timestamp>,v1=<hex_hmac>
```

To verify a delivery:

1. Pull `t` and `v1` out of the header.
2. Reject if `t` is more than 5 minutes old (replay protection).
3. Compute `HMAC-SHA256(secret, "{t}.{raw_request_body}")` and compare to `v1` using a constant-time comparison.

Use the **raw request body bytes** — don't re-serialize the parsed JSON, or whitespace differences will break the signature.

### Node.js

```javascript theme={null}
import crypto from "crypto";

function verifyAcreLensWebhook(rawBody, signatureHeader, secret, skewSeconds = 300) {
  const parts = Object.fromEntries(
    signatureHeader.split(",").map(p => p.split("="))
  );
  const timestamp = parseInt(parts.t, 10);
  const signature = parts.v1;

  if (Math.abs(Date.now() / 1000 - timestamp) > skewSeconds) {
    return false; // replay / clock skew
  }

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

  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(signature, "hex")
    );
  } catch {
    return false;
  }
}

// Express handler — note express.raw() to get the raw body
app.post(
  "/webhooks/acrelens",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifyAcreLensWebhook(
      req.body.toString("utf8"),
      req.headers["x-acrelens-signature"],
      process.env.ACRELENS_WEBHOOK_SECRET
    );
    if (!ok) return res.status(401).send("Invalid signature");

    const event = JSON.parse(req.body);
    // ... handle event
    res.status(200).send("ok");
  }
);
```

### Python

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

def verify_acrelens_webhook(raw_body: bytes, signature_header: str, secret: str, skew: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = int(parts["t"])
    signature = parts["v1"]

    if abs(time.time() - timestamp) > skew:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.{raw_body.decode('utf-8')}".encode(),
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)
```

## Webhook secret

Your webhook secret lives in the dashboard under **Manage → Webhooks**. It's:

* Per-customer (one secret used for all your webhook deliveries)
* Rotatable (creates a new secret; old one immediately stops working)
* Treated as sensitive — store it like an API key, in env vars or a secret manager

## Retry schedule

If your endpoint returns a non-2xx status, doesn't respond within **30 seconds**, or has a network error, AcreLens retries up to **7 times** total (the initial attempt plus 6 retries):

| Attempt | Delay after previous |
| - | - |
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
| 7 | 24 hours |

**Retried** on: 5xx, 408, 429, network errors, timeouts.
**Not retried** on: 2xx, 3xx, all other 4xx (including 410 Gone, which signals "stop trying").

After all retries are exhausted, the delivery is marked failed and visible in the dashboard. There's no automatic resurrection — you'd need to refetch the report manually if you want the data.

## Idempotency in your handler

AcreLens may deliver the same event more than once if your endpoint is slow to respond. Use the `X-AcreLens-Delivery` header (unique per delivery attempt) to dedupe — but better, key off `report_id` since that's stable across retries:

```javascript theme={null}
async function handleReportCompleted(payload) {
  const exists = await db.processedReports.findOne({ id: payload.report_id });
  if (exists) return; // already handled

  await db.transaction(async (tx) => {
    await tx.processedReports.insert({ id: payload.report_id });
    await processReport(payload);
  });
}
```

## Local development

Use `ngrok`, `cloudflared`, or `tailscale funnel` to expose your local server. Drop the public URL into your dashboard's webhook config — there's no separate sandbox environment to wire up.

## Failures don't affect quota

Webhook delivery failures don't consume API quota or cost you anything beyond the original report charge. Reports are still available via `GET /v1/reports/{id}` if your webhook endpoint never receives them.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.