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

# Webhooks

> Receive signed, real-time event notifications instead of polling.

MicroCrop POSTs events to an HTTPS endpoint you control, so you don't have to poll. Each
delivery is **signed** so you can verify it came from MicroCrop, **retried** with backoff
if your endpoint is unavailable, and **logged** so you can replay one by hand.

## 1. Configure your endpoint

Webhook configuration lives under `/api/organizations/me/webhook`. Every route below
requires `ORG_ADMIN` — your organization API key holds it.

```bash theme={null}
# set (or change) the endpoint. Body key is `url`, and it must be https.
curl -X PUT https://app.microcrop.app/api/organizations/me/webhook \
  -H "x-api-key: $KEY" -H "Content-Type: application/json" \
  -d '{ "url": "https://your-system.example.com/microcrop/webhooks" }'
```

```json theme={null}
{ "success": true, "data": {
  "url": "https://your-system.example.com/microcrop/webhooks",
  "secretSet": true,
  "secret": "whsec_9f21…"
} }
```

A `webhookSecret` is generated automatically the first time you set a `webhookUrl`, and
`PUT` returns it in the same response. Read it back any time:

```bash theme={null}
curl https://app.microcrop.app/api/organizations/me/webhook -H "x-api-key: $KEY"
# → data.url, data.secretSet, data.secret
```

Rotate it if it leaks — the new value is returned once, and deliveries signed with the
old secret stop verifying immediately:

```bash theme={null}
curl -X POST https://app.microcrop.app/api/organizations/me/webhook/rotate-secret \
  -H "x-api-key: $KEY"
# → { "success": true, "data": { "secret": "whsec_…" } }
```

<Warning>
  `PUT /api/organizations/me/settings` does **not** configure webhooks. It accepts only
  `brandColor` and `contactPhone`; a `webhookUrl` sent there is silently discarded and the
  call still returns `200`. Use `PUT /api/organizations/me/webhook`.

  `GET /api/organizations/me` does **not** return `webhookSecret` either — it strips it,
  along with `apiKey` and `apiSecret`. Use `GET /api/organizations/me/webhook`.
</Warning>

## 2. Events

| Event                                      | Fires when                                                                               | `data`                                                                                                                                                                            |
| ------------------------------------------ | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `policy.activated`                         | A policy is paid and activated on-chain                                                  | `policyId, onChainPolicyId, policyNumber, farmerId, sumInsured, txHash`                                                                                                           |
| `determination.delivered`                  | A signed determination has been issued for one of your policies                          | `determinationId, policyId, policyNumber, kind, methodologyVersion, triggered, damagePercentBp, assessedAt, canonicalHash, settlementMode, settledByMicrocrop, amountOwed, links` |
| `determination.settlement_report_recorded` | You told us what you paid the farmer for a Tier 1 determination — including a correction | `determinationId, policyId, policyNumber, settlementMode, settledByMicrocrop, verifiedByMicrocrop, verificationStatus, partnerSettlementStatus, report, correction, links, note`  |
| `determination.settlement_report_overdue`  | A Tier 1 determination passed its reporting due date with no report                      | `determinationId, policyId, policyNumber, settlementMode, settledByMicrocrop, partnerSettlementStatus, dueAt, determinedAt, links, note`                                          |
| `payout.completed`                         | A payout is delivered to the farmer (Tier 2 only)                                        | `payoutId, policyId, amountUSDC, amountKES, mpesaRef`                                                                                                                             |
| `payout.failed`                            | A payout permanently failed after retries (Tier 2 only)                                  | `payoutId, policyId, amountUSDC, reason`                                                                                                                                          |
| `kyb.verified`                             | Your organization is verified                                                            | `organizationId, kybStatus`                                                                                                                                                       |
| `kyb.rejected`                             | Your KYB submission was rejected                                                         | `organizationId, kybStatus, notes`                                                                                                                                                |

The three `determination.*` events are the whole notification surface for a
[Tier 1](/guides/service-tiers) integration; `payout.*` never fires there, because
MicroCrop settles nothing.

## 3. Payload & headers

Every delivery is a JSON POST with this envelope:

```json theme={null}
{
  "id": "e3b0c442-…",
  "event": "payout.completed",
  "createdAt": "2026-09-09T12:34:56.000Z",
  "data": { "payoutId": "…", "policyId": "…", "amountUSDC": 480, "amountKES": 62000, "mpesaRef": "…" }
}
```

Headers:

| Header                  | Value                                         |
| ----------------------- | --------------------------------------------- |
| `x-microcrop-event`     | The event type (e.g. `payout.completed`).     |
| `x-microcrop-delivery`  | Unique delivery id (same as `body.id`).       |
| `x-microcrop-timestamp` | Unix **seconds** when the payload was signed. |
| `x-microcrop-signature` | HMAC-SHA256 signature, lowercase hex.         |
| `User-Agent`            | `MicroCrop-Webhooks/1.0`                      |

### `determination.delivered`

Its `data` is **currency-neutral and chain-neutral in both service tiers** — one payload
shape, one parser:

```json theme={null}
{
  "id": "8a1c…",
  "event": "determination.delivered",
  "createdAt": "2026-09-09T06:14:22.101Z",
  "data": {
    "determinationId": "b1f0…",
    "policyId": "4444…",
    "policyNumber": "MC-KE-2026-000123",
    "kind": "CROP_DAMAGE",
    "methodologyVersion": "crop-dualindex-1.0",
    "triggered": true,
    "damagePercentBp": 1650,
    "assessedAt": "2026-09-02T00:00:00.000Z",
    "canonicalHash": "5e2151…",
    "settlementMode": "DETERMINATION",
    "settledByMicrocrop": false,
    "amountOwed": { "amountMinor": "1650000", "amount": "16500.00", "currency": "KES", "exponent": 2, "damagePercentBp": 1650, "basis": "policy.sumInsured × damagePercentBp / 10000, truncated to the minor unit" },
    "links": {
      "self": "/api/determinations/b1f0…",
      "evidence": "/api/determinations/b1f0…/evidence"
    }
  }
}
```

`amountOwed` is stated in **your policy's own currency**, derived as
`sumInsured × damagePercentBp / 10000` truncated to the minor unit — never USDC, and
with no exchange rate anywhere in the path. If your policy's currency has no registered
minor-unit exponent, `amountOwed` is `null` and `amountOwedUnavailableReason` is present
instead. See [Determinations](/guides/determinations). On the
`DETERMINATION_AND_SETTLEMENT` tier you still receive the settled figure separately on
`payout.completed`, which is where `amountUSDC` belongs.

The event fires **once per determination**. It is keyed on the same idempotency key
MicroCrop uses to ingest the signed determination, so an oracle resubmitting the same
envelope emits nothing further. It is not emitted at all for a determination that could
not be attributed to one of your policies.

### `determination.settlement_report_recorded`

Fires for **every newly recorded** Tier 1 settlement report, corrections included. A
replayed report — same `determinationId` and `partnerReference` — writes nothing and
emits nothing.

```json theme={null}
{
  "determinationId": "b1f0…",
  "policyId": "4444…",
  "policyNumber": "MC-KE-2026-000123",
  "settlementMode": "DETERMINATION",
  "settledByMicrocrop": false,
  "verifiedByMicrocrop": false,
  "verificationStatus": "UNVERIFIED",
  "partnerSettlementStatus": "REPORTED",
  "report": {
    "id": "r1…",
    "partnerReference": "QK73HG9XYZ",
    "outcome": "SETTLED_FULL",
    "method": "MOBILE_MONEY",
    "settledAmountMinor": "1650000",
    "settlementCurrency": "KES",
    "settledAt": "2026-09-02T09:14:00.000Z",
    "reportedAt": "2026-09-02T09:20:11.004Z",
    "supersedesReportId": null
  },
  "correction": false,
  "links": {
    "determination": "/api/determinations/b1f0…",
    "settlementReport": "/api/determinations/b1f0…/settlement-report"
  },
  "note": "This is a PARTNER-ATTESTED statement of what the partner says it paid its farmer off-platform, off its own balance sheet. MicroCrop did NOT settle this policy, did not observe the payment and has NOT verified this report."
}
```

When `correction` is `true`, `report.supersedesReportId` names the report this one
replaces — **replace** your stored attestation rather than appending a second one.

### `determination.settlement_report_overdue`

Fires at most once per determination, when a Tier 1 determination reaches its reporting
due date with no report on file. It is a **reporting** state and nothing more: MicroCrop
did not settle the policy, owes nothing on it and has moved no money.

```json theme={null}
{
  "determinationId": "b1f0…",
  "policyId": "4444…",
  "policyNumber": "MC-KE-2026-000123",
  "settlementMode": "DETERMINATION",
  "settledByMicrocrop": false,
  "partnerSettlementStatus": "OVERDUE",
  "dueAt": "2026-10-02T06:14:22.101Z",
  "determinedAt": "2026-09-02T06:14:22.101Z",
  "links": { "determination": "…", "settlementReport": "…" },
  "note": "MicroCrop has not received a settlement report for this determination by its reporting due date. …"
}
```

## 4. Verify the signature

Compute `HMAC-SHA256(secret, "{timestamp}.{rawBody}")` and compare, in constant time, to
`x-microcrop-signature`. **Use the raw request body** (the exact bytes received), never a
re-serialized object — key order and whitespace would differ and the comparison would
fail.

```js theme={null}
import crypto from "node:crypto";
import express from "express";

const app = express();
// Capture the raw bytes BEFORE any JSON parsing.
app.use("/microcrop/webhooks", express.raw({ type: "application/json" }));

app.post("/microcrop/webhooks", (req, res) => {
  const ts  = req.headers["x-microcrop-timestamp"];
  const sig = req.headers["x-microcrop-signature"];
  const raw = req.body;                       // Buffer — the exact bytes MicroCrop signed

  const expected = crypto
    .createHmac("sha256", process.env.MICROCROP_WEBHOOK_SECRET)
    .update(`${ts}.${raw.toString("utf8")}`)
    .digest("hex");

  const a = Buffer.from(sig ?? "", "hex");
  const b = Buffer.from(expected, "hex");
  const ok = a.length === b.length && crypto.timingSafeEqual(a, b);

  // Reject stale deliveries (replay protection). The header is unix SECONDS.
  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; // 5 minutes

  if (!ok || !fresh) return res.status(401).end();

  const event = JSON.parse(raw.toString("utf8"));
  enqueueForProcessing(event);                // do the work asynchronously
  res.status(200).end();
});
```

Respond `2xx` quickly — MicroCrop aborts the request after **10 seconds** and treats the
timeout as a failed attempt. Do heavy work asynchronously.

## 5. Retries & delivery semantics

* Non-`2xx` responses (or timeouts) are retried up to **5 attempts** with exponential
  backoff (\~30s, 1m, 2m, 4m, 8m). After the last attempt the delivery is marked `FAILED`.
* Delivery is **at-least-once** — the same event may arrive more than once.
  **Deduplicate by `id`** (or `x-microcrop-delivery`); automatic and manual retries
  re-send the **same `body.id`**, so deduplicating on it is safe across every path.
* Order is not guaranteed; treat each event as a state assertion and reconcile.

### Delivery log

```bash theme={null}
curl "https://app.microcrop.app/api/organizations/me/webhook/deliveries?status=FAILED&page=1&limit=50" \
  -H "x-api-key: $KEY"
```

```json theme={null}
{ "success": true, "data": {
  "deliveries": [
    { "id": "e3b0…", "event": "determination.delivered",
      "url": "https://your-system.example.com/microcrop/webhooks",
      "status": "FAILED", "attempts": 5, "responseStatus": 502,
      "lastError": "HTTP 502", "deliveredAt": null,
      "createdAt": "…", "updatedAt": "…" }
  ],
  "total": 1, "page": 1, "limit": 50
} }
```

`status` filters on `PENDING`, `DELIVERED` or `FAILED`; `limit` is capped at 100. The
delivery **payload is not returned** by this endpoint — only the attempt metadata.

### Manual retry

```bash theme={null}
curl -X POST https://app.microcrop.app/api/organizations/me/webhook/deliveries/e3b0…/retry \
  -H "x-api-key: $KEY"
# → { "success": true, "data": { "id": "e3b0…", "event": "…", "status": "PENDING", "attempts": 5 } }
```

Re-enqueues the stored payload against your **current** `webhookUrl` and **current**
secret, resets the row to `PENDING`, and re-sends the same `body.id`. It returns
`400 INVALID_INPUT` if the organization has no webhook URL configured, and
`404 NOT_FOUND` for a delivery id that is not yours.

## 6. Fallback: polling

Webhooks are a convenience layer over the same state you can always
[poll](/guides/status-tracking). If you miss deliveries beyond the retry window,
reconcile with `GET /api/determinations`, `GET /api/policies/{id}/status` and (on Tier 2)
`GET /api/payouts`.

<Note>
  Need an event we don't emit yet (e.g. `policy.expired`, `payout.processing`)? Tell your
  MicroCrop contact — the event set is easy to extend.
</Note>
