← Docs

Webhooks

Nextfarm sends a POST to your system when something happens on a farm. The farm owner adds a receiving URL (https://) and picks events in System › Webhooks.

Events

EventGroupWhen
iot.alert.raisedIoTA sensor crossed a threshold (temperature, humidity, EC…): device, metric, value, threshold, severity.
iot.device.offlineIoTA device stopped reporting for longer than allowed.
harvest.batch.createdHarvestA harvest record was declared and confirmed: parcel, crop type, yield (kg).
harvest.batch.verifiedHarvestA harvest batch was verified: actual yield, quality grade, harvest date.
harvest.qr.createdTraceabilityA traceability QR label was created for a harvest batch or season.
gis.season.createdSeasonsA season was created on a parcel: crop, season name, planting date, expected harvest date.
gis.season.closedSeasonsA season was closed: closing date and yield (kg) if recorded.
gis.parcel.createdParcelsA new parcel was added to the farm: zone, owner, crop and area (ha).
office.task.completedTasksA farm task was marked done: title, who completed it, when, and the linked parcel/season.

Outgoing message

Example
POST https://erp.your-company.com/hooks/nextfarm
Content-Type: application/json
X-Nextfarm-Event: iot.alert.raised
X-Nextfarm-Delivery: 0199...          (delivery ID — use it to de-duplicate)
X-Nextfarm-Timestamp: 1790467200      (Unix seconds)
X-Nextfarm-Signature: sha256=9c1e...
webhook-id: 0199...
webhook-timestamp: 1790467200
webhook-signature: v1,K5oZfz...

{
  "id": "0199...", "type": "iot.alert.raised",
  "workspace_id": "...", "occurred_at": "2026-09-27T03:10:00Z",
  "data": { "alert_id": "...", "device_id": "...", "device_code": "NK2", "zone_id": "...",
            "parcel_ids": [], "metric": "temperature", "value": 41.2, "threshold": 38,
            "severity": "high", "message": "..." }
}
iot.alert.raisedIoTscope: iot.alerts.view

New sensor alert

A sensor crossed a threshold (temperature, humidity, EC…): device, metric, value, threshold, severity.

Example data

Example
{
  "alert_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "device_code": "NK2",
  "device_id": "0190a3c2-5b1e-7c3d-8e4f-000000000002",
  "message": "Nhiệt độ nhà kính 2 vượt 38°C",
  "metric": "temperature",
  "parcel_ids": [],
  "severity": "high",
  "threshold": 38,
  "value": 41.2,
  "zone_id": "0190a3c2-5b1e-7c3d-8e4f-000000000003"
}
JSON Schema
Example
{
  "properties": {
    "alert_id": {
      "format": "uuid",
      "type": "string"
    },
    "device_code": {
      "type": "string"
    },
    "device_id": {
      "format": "uuid",
      "type": "string"
    },
    "message": {
      "type": "string"
    },
    "metric": {
      "type": "string"
    },
    "parcel_ids": {
      "items": {
        "format": "uuid",
        "type": "string"
      },
      "type": "array"
    },
    "severity": {
      "type": [
        "string",
        "null"
      ]
    },
    "threshold": {
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
      "type": [
        "string",
        "number"
      ]
    },
    "value": {
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$",
      "type": [
        "string",
        "number"
      ]
    },
    "zone_id": {
      "format": "uuid",
      "type": "string"
    }
  },
  "required": [
    "alert_id",
    "device_id",
    "device_code",
    "zone_id",
    "parcel_ids",
    "metric",
    "value",
    "threshold",
    "severity",
    "message"
  ],
  "type": "object"
}
iot.device.offlineIoTscope: iot.devices.view

Device offline

A device stopped reporting for longer than allowed.

Example data

Example
{
  "device_code": "NK2",
  "device_id": "0190a3c2-5b1e-7c3d-8e4f-000000000002",
  "device_name": "Bộ tưới nhà kính 2",
  "last_heartbeat": "2026-10-28T06:45:00+00:00",
  "parcel_ids": [],
  "zone_id": "0190a3c2-5b1e-7c3d-8e4f-000000000003"
}
JSON Schema
Example
{
  "properties": {
    "device_code": {
      "type": "string"
    },
    "device_id": {
      "format": "uuid",
      "type": "string"
    },
    "device_name": {
      "type": "string"
    },
    "last_heartbeat": {
      "format": "date-time",
      "type": [
        "string",
        "null"
      ]
    },
    "parcel_ids": {
      "items": {
        "format": "uuid",
        "type": "string"
      },
      "type": "array"
    },
    "zone_id": {
      "format": "uuid",
      "type": "string"
    }
  },
  "required": [
    "device_id",
    "device_code",
    "device_name",
    "zone_id",
    "parcel_ids",
    "last_heartbeat"
  ],
  "type": "object"
}
harvest.batch.createdHarvestscope: harvest.harvest.view

Harvest batch confirmed

A harvest record was declared and confirmed: parcel, crop type, yield (kg).

Example data

Example
{
  "confirmed_at": "2026-10-28T07:30:00+00:00",
  "crop_type_id": "0190a3c2-5b1e-7c3d-8e4f-000000000003",
  "harvest_record_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "parcel_id": "0190a3c2-5b1e-7c3d-8e4f-000000000002",
  "quantity_kg": 1250.5
}
JSON Schema
Example
{
  "properties": {
    "confirmed_at": {
      "format": "date-time",
      "type": "string"
    },
    "crop_type_id": {
      "format": "uuid",
      "type": "string"
    },
    "harvest_record_id": {
      "format": "uuid",
      "type": "string"
    },
    "parcel_id": {
      "format": "uuid",
      "type": "string"
    },
    "quantity_kg": {
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
      "type": [
        "string",
        "number"
      ]
    }
  },
  "required": [
    "harvest_record_id",
    "parcel_id",
    "crop_type_id",
    "quantity_kg",
    "confirmed_at"
  ],
  "type": "object"
}
harvest.batch.verifiedHarvestscope: harvest.harvest.view

Harvest batch verified

A harvest batch was verified: actual yield, quality grade, harvest date.

Example data

Example
{
  "actual_quantity_kg": 1198,
  "crop_type_id": "0190a3c2-5b1e-7c3d-8e4f-000000000003",
  "crop_type_name": "Vải thiều",
  "harvest_date": "2026-10-27",
  "harvest_record_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "parcel_id": "0190a3c2-5b1e-7c3d-8e4f-000000000002",
  "quality_grade": "A",
  "season_id": null,
  "verified_at": "2026-10-28T07:30:00+00:00"
}
JSON Schema
Example
{
  "properties": {
    "actual_quantity_kg": {
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
      "type": [
        "string",
        "number"
      ]
    },
    "crop_type_id": {
      "format": "uuid",
      "type": "string"
    },
    "crop_type_name": {
      "type": [
        "string",
        "null"
      ]
    },
    "harvest_date": {
      "format": "date",
      "type": "string"
    },
    "harvest_record_id": {
      "format": "uuid",
      "type": "string"
    },
    "parcel_id": {
      "format": "uuid",
      "type": "string"
    },
    "quality_grade": {
      "type": [
        "string",
        "null"
      ]
    },
    "season_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "verified_at": {
      "format": "date-time",
      "type": "string"
    }
  },
  "required": [
    "harvest_record_id",
    "parcel_id",
    "season_id",
    "crop_type_id",
    "crop_type_name",
    "actual_quantity_kg",
    "quality_grade",
    "harvest_date",
    "verified_at"
  ],
  "type": "object"
}
harvest.qr.createdTraceabilityscope: harvest.qr-codes.view

Traceability QR created

A traceability QR label was created for a harvest batch or season.

Example data

Example
{
  "code": "NF-7Q2K-91XD",
  "created_at": "2026-10-28T07:30:00+00:00",
  "harvest_record_id": "0190a3c2-5b1e-7c3d-8e4f-000000000002",
  "qr_code_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "season_id": null
}
JSON Schema
Example
{
  "properties": {
    "code": {
      "type": "string"
    },
    "created_at": {
      "format": "date-time",
      "type": "string"
    },
    "harvest_record_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "qr_code_id": {
      "format": "uuid",
      "type": "string"
    },
    "season_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    }
  },
  "required": [
    "qr_code_id",
    "code",
    "season_id",
    "harvest_record_id",
    "created_at"
  ],
  "type": "object"
}
gis.season.createdSeasonsscope: gis.parcels.view

Season created

A season was created on a parcel: crop, season name, planting date, expected harvest date.

Example data

Example
{
  "branch_id": null,
  "crop_name": "Lúa",
  "crop_type_id": "0190a3c2-5b1e-7c3d-8e4f-000000000003",
  "expected_harvest_on": "2027-02-20",
  "parcel_code": "A-12",
  "parcel_id": "0190a3c2-5b1e-7c3d-8e4f-000000000002",
  "planted_on": "2026-11-05",
  "season_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "season_name": "Đông Xuân 2026"
}
JSON Schema
Example
{
  "properties": {
    "branch_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "crop_name": {
      "type": "string"
    },
    "crop_type_id": {
      "format": "uuid",
      "type": "string"
    },
    "expected_harvest_on": {
      "format": "date",
      "type": [
        "string",
        "null"
      ]
    },
    "parcel_code": {
      "type": "string"
    },
    "parcel_id": {
      "format": "uuid",
      "type": "string"
    },
    "planted_on": {
      "format": "date",
      "type": "string"
    },
    "season_id": {
      "format": "uuid",
      "type": "string"
    },
    "season_name": {
      "type": "string"
    }
  },
  "required": [
    "season_id",
    "season_name",
    "parcel_id",
    "parcel_code",
    "branch_id",
    "crop_type_id",
    "crop_name",
    "planted_on",
    "expected_harvest_on"
  ],
  "type": "object"
}
gis.season.closedSeasonsscope: gis.parcels.view

Season closed

A season was closed: closing date and yield (kg) if recorded.

Example data

Example
{
  "closed_at": "2026-10-28T07:30:00+00:00",
  "closed_on": "2027-02-21",
  "season_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "yield_kg": 5400
}
JSON Schema
Example
{
  "properties": {
    "closed_at": {
      "format": "date-time",
      "type": "string"
    },
    "closed_on": {
      "format": "date",
      "type": "string"
    },
    "season_id": {
      "format": "uuid",
      "type": "string"
    },
    "yield_kg": {
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
      "type": [
        "string",
        "number",
        "null"
      ]
    }
  },
  "required": [
    "season_id",
    "closed_on",
    "yield_kg",
    "closed_at"
  ],
  "type": "object"
}
gis.parcel.createdParcelsscope: gis.parcels.view

Parcel created

A new parcel was added to the farm: zone, owner, crop and area (ha).

Example data

Example
{
  "area_ha": 1.25,
  "created_at": "2026-10-28T07:30:00+00:00",
  "crop_type_id": "0190a3c2-5b1e-7c3d-8e4f-000000000003",
  "owner_id": null,
  "parcel_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "zone_id": "0190a3c2-5b1e-7c3d-8e4f-000000000002"
}
JSON Schema
Example
{
  "properties": {
    "area_ha": {
      "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$",
      "type": [
        "string",
        "number"
      ]
    },
    "created_at": {
      "format": "date-time",
      "type": "string"
    },
    "crop_type_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "owner_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "parcel_id": {
      "format": "uuid",
      "type": "string"
    },
    "zone_id": {
      "format": "uuid",
      "type": "string"
    }
  },
  "required": [
    "parcel_id",
    "zone_id",
    "owner_id",
    "crop_type_id",
    "area_ha",
    "created_at"
  ],
  "type": "object"
}
office.task.completedTasksscope: office.task.view

Task completed

A farm task was marked done: title, who completed it, when, and the linked parcel/season.

Example data

Example
{
  "branch_id": null,
  "completed_at": "2026-10-28T07:30:00+00:00",
  "completed_by": "0190a3c2-5b1e-7c3d-8e4f-000000000002",
  "parcel_id": "0190a3c2-5b1e-7c3d-8e4f-000000000003",
  "season_id": null,
  "task_id": "0190a3c2-5b1e-7c3d-8e4f-000000000001",
  "title": "Bón thúc đợt 2 thửa A-12"
}
JSON Schema
Example
{
  "properties": {
    "branch_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "completed_at": {
      "format": "date-time",
      "type": "string"
    },
    "completed_by": {
      "format": "uuid",
      "type": "string"
    },
    "parcel_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "season_id": {
      "format": "uuid",
      "type": [
        "string",
        "null"
      ]
    },
    "task_id": {
      "format": "uuid",
      "type": "string"
    },
    "title": {
      "type": "string"
    }
  },
  "required": [
    "task_id",
    "title",
    "completed_by",
    "completed_at",
    "branch_id",
    "parcel_id",
    "season_id"
  ],
  "type": "object"
}

Verifying the signature

Fastest: Standard Webhooks

Every message also carries 3 headers from the open Standard Webhooks spec: webhook-id (= X-Nextfarm-Delivery), webhook-timestamp, webhook-signature: v1,…. Use the standardwebhooks library (Node, Python, Go, Java, C#, PHP, Ruby…) with your whsec_… secret as-is — it checks the signature and clock skew for you. Applies to every URL, including ones created before 14/10/2026.

Node.js — npm i standardwebhooks
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.NEXTFARM_WEBHOOK_SECRET);   // "whsec_..."
app.post("/hooks/nextfarm", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = wh.verify(req.body, req.headers);   // ném lỗi / throws if invalid
    res.sendStatus(200);
  } catch {
    res.sendStatus(401);
  }
});
Python — pip install standardwebhooks
from standardwebhooks.webhooks import Webhook
wh = Webhook(SECRET)   # "whsec_..."
@app.post("/hooks/nextfarm")
def hook():
    try:
        event = wh.verify(request.get_data(), dict(request.headers))
    except Exception:
        return "", 401
    return "", 200

Or verify X-Nextfarm-Signature yourself:

X-Nextfarm-Signature = sha256= + hex of HMAC-SHA256(secret, timestamp + "." + body). The secret (whsec_…) is shown once, when the URL is created or rotated. Reject messages whose timestamp is off by more than 5 minutes.

Node.js (Express)
import crypto from "node:crypto";
// needs the RAW body — express.raw, not express.json
app.post("/hooks/nextfarm", express.raw({ type: "application/json" }), (req, res) => {
  const ts = req.header("X-Nextfarm-Timestamp");
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.NEXTFARM_WEBHOOK_SECRET)
    .update(ts + "." + req.body).digest("hex");
  const got = req.header("X-Nextfarm-Signature") ?? "";
  const ok = got.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(got));
  if (!ok || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
  const event = JSON.parse(req.body);   // de-duplicate on X-Nextfarm-Delivery
  res.sendStatus(200);
});
Python (Flask)
import hmac, hashlib, time
@app.post("/hooks/nextfarm")
def hook():
    ts = request.headers["X-Nextfarm-Timestamp"]
    body = request.get_data()
    expected = "sha256=" + hmac.new(SECRET.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Nextfarm-Signature", "")) \
       or abs(time.time() - int(ts)) > 300:
        return "", 401
    return "", 200

Retries and pausing

  • Returning 2xx within 10 seconds counts as success. Any other code, a timeout or a redirect (3xx) counts as a failure.
  • Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours (7 attempts in total).
  • 5 consecutive deliveries that fail every attempt ⇒ the URL is paused automatically; re-enable it on the Webhooks screen.
  • The Webhooks screen has Send test (a webhook.test message), a log of the last 50 deliveries, Redeliver and Rotate secret — the old secret co-signs for 24 more hours (X-Nextfarm-Signature-Previous + a second signature in webhook-signature).
The receiving URL must be https:// to a public server. Nextfarm rejects private addresses (localhost, 10.x, 192.168.x, 169.254.x…) both when saving and when sending.

App Webhooks (for partners)

Different from the farm webhooks above: these are messages NextFarm sends to your app — ONE Callback URL receives events from every installed farm (the body carries workspace_id). app.* events always arrive when a farm installs, removes or changes scopes; you can also turn on data events (same names and bodies as farm webhooks) — sent only for farms that GRANTED the matching scope. Configure them in My apps › app › Webhooks; signed per Standard Webhooks with the app's own whsec_ signing secret (after rotation the old one co-signs for 24 hours) — verify with the standardwebhooks library. Failures retry after 1′, 5′, 30′, 2h, 6h, 24h; 5 consecutive failed events ⇒ paused + an email.

type
app.installedA farm just allowed the app (first time or reinstall).
app.scopes_changedThe farm changed the granted scopes — old tokens are blocked, request a new one.
app.uninstalledThe farm removed the app — its tokens are blocked immediately.
app.pingTest message (Send test button, and during the Publish checklist).
harvest.batch.createdData event — a farm confirms a harvest batch (needs harvest.harvest.view).
iot.alert.raisedData event — a sensor crosses a threshold (needs iot.alerts.view).
iot.device.offlineData event — a device goes offline (needs iot.devices.view).
app.installed
{ "id": "0199…", "type": "app.installed", "created_at": "2026-10-14T09:12:00Z",
  "data": { "client_id": "nfa_8f3c…", "workspace_id": "019d…3c1",
            "workspace_name": "Trại Ninh", "scopes": ["harvest.harvest.view"] } }

Full security rules for partners: Security →