agmission/server/docs/DATA_EXPORT_CUSTOMER_INTEGRATION_GUIDE.md

1333 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AgMission Data Export API — Customer Integration Guide
**Audience**: Technical Integrators, BI Teams, Data Warehouse Engineers
**Version**: 1.0
**Last Updated**: May 2026
---
## Table of Contents
1. [Overview](#overview)
2. [Quick Start](#quick-start)
3. [Authentication](#authentication)
4. [API Endpoints](#api-endpoints)
5. [Rate Limiting](#rate-limiting)
6. [Data Formats](#data-formats)
7. [Use Cases](#use-cases)
8. [Error Handling](#error-handling)
9. [Support & SLAs](#support--slas)
---
## Overview
The **AgMission Data Export API** provides programmatic access to spray application data for integration with business intelligence tools, data warehouses, and custom systems.
### Capabilities
- **Real-time session summaries** — Coverage, timing, pilot, aircraft info (GET `/api/v1/jobs/:jobId/sessions`)
- **Raw GPS trace records** — Point-by-point telemetry with cursor pagination (GET `/api/v1/jobs/:jobId/sessions/:fileId/records`)
- **Spray area polygons** — GeoJSON boundaries for mapping (GET `/api/v1/jobs/:jobId/areas`)
- **Async bulk export** — CSV or JSON for full data lake ingestion (POST/GET `/api/v1/jobs/:jobId/export`)
### Who Should Use This API
| Role | Use Case |
|---|---|
| BI Engineer | Power BI incremental refresh, Tableau connectors |
| Data Warehouse | Nightly batch loads, transformation pipelines |
| GIS Analyst | ArcGIS layer ingestion, spatial analysis |
| Compliance Officer | Audit trails, proof-of-application records |
| Agronomist | Yield correlation, efficacy analysis |
### Architecture
```mermaid
graph TD
ext[Your System]
gw[AgMission API Gateway - Auth and Rate Limiting]
sess[GET /api/v1/jobs/:id/sessions]
recs[GET /api/v1/jobs/:id/sessions/:fid/records]
areas[GET /api/v1/jobs/:id/areas]
exp[POST /api/v1/jobs/:id/export]
stat[GET /api/v1/exports/:id]
dl[GET /api/v1/exports/:id/download]
data[(AgMission Data Services)]
ext -->|X-API-Key over HTTPS| gw
gw --> sess
gw --> recs
gw --> areas
gw --> exp
gw --> stat
gw --> dl
sess --> data
recs --> data
areas --> data
exp --> data
stat --> data
dl --> data
style ext fill:#e3f2fd
style gw fill:#f3e5f5
style data fill:#e8f5e9
```
---
## Quick Start
### 1. Get an API Key
Contact your AgMission account manager or self-serve using a Master Account at `https://agmission.agnav.com/api-keys`:
```
Test Export: 3v8x2j9kL4m5nQ6... (test key)
Live Export: 7p2r9w4tY3h8k1... (production key)
...
```
### 2. List sessions for a job
```bash
JOB_ID=12345
API_KEY="3v8x2j9kL4m5nQ6..."
curl -X GET "https://api.agmission.com/api/v1/jobs/${JOB_ID}/sessions" \
-H "X-API-Key: ${API_KEY}"
```
**Response**:
```json
{
"jobId": 12345,
"clientId": "507f1f77bcf86cd799439055",
"clientName": "Fazenda São Paulo Ltda",
"assignedPilotId": "507f1f77bcf86cd799439033",
"assignedPilotName": "John Smith",
"assignedAircraftId": "507f1f77bcf86cd799439044",
"assignedAircraftName": "AT-802F",
"assignedAircraftTailNumber": "N1234AT",
"planAircraftName": "AT-802F",
"planAircraftTailNumber": "N1234AT",
"assignedDate": "2026-04-21T18:00:00Z",
"mappedArea_ha": 48.5,
"reportConfirmed": false,
"areaSize_ha": 48.5,
"coverage_ha": 45.2,
"overSprayed_pct": -6.80,
"appRate": 50,
"appRateUnit": "lit/ha",
"appRateConfirmed": null,
"sprayVolume": 2260,
"volumeUnit": "lit",
"useConfirmedVolume": false,
"actualSprayVolume": 2260,
"confirmedActualVolume": null,
"effectiveVolume": 2260,
"useCustomWeather": false,
"weather": null,
"data": [
{
"sessionId": "507f1f77bcf86cd799439011",
"fileName": "flight_20260422_001.log",
"startDateTime": "2026-04-22T09:00:00Z",
"endDateTime": "2026-04-22T11:30:00Z",
"totalFlightTime_s": 9000,
"totalSprayTime_s": 7200,
"totalTurnTime_s": 1800,
"totalSprayed_ha": 45.2,
"totalSprayMat": 2260,
"totalSprayMatUnit": "lit",
"avgSpraySpeed_ms": 39.5,
"sprayZoneName": "Field A North",
"sprayZoneArea_ha": 25.0,
"appRate": 50,
"appRateUnit": "lit/ha",
"flowController": "Ag-Flow UFC",
"sprayOnLag_s": 0.2,
"sprayOffLag_s": 0.15,
"pulsesPerLiter": 1800,
"files": [
{ "fileId": "507f1f77bcf86cd799439022", "name": "n5021813.t44" }
],
"sessionPilotName": "John Smith"
}
]
}
```
### 3. Export to CSV
```bash
JOB_ID=12345
API_KEY="3v8x2j9kL4m5nQ6..."
# Trigger export (async)
EXPORT_ID=$(curl -s -X POST "https://api.agmission.com/api/v1/jobs/${JOB_ID}/export" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"format":"csv","units":"metric"}' \
| jq -r '.exportId')
echo "Export ID: $EXPORT_ID"
# Poll for completion
while true; do
STATUS=$(curl -s -X GET "https://api.agmission.com/api/v1/exports/${EXPORT_ID}" \
-H "X-API-Key: ${API_KEY}" \
| jq -r '.status')
echo "Status: $STATUS"
if [ "$STATUS" = "ready" ]; then
break
fi
sleep 5
done
# Download
curl -X GET "https://api.agmission.com/api/v1/exports/${EXPORT_ID}/download" \
-H "X-API-Key: ${API_KEY}" \
-o "export_job${JOB_ID}.csv"
echo "Downloaded: export_job${JOB_ID}.csv"
```
---
## Authentication
### API Key Format
API keys are **Bearer tokens** supplied via the `X-API-Key` header (NOT `Authorization` header).
```mermaid
sequenceDiagram
participant Client as Your System
participant API as AgMission API
participant Auth as Auth Middleware
participant KeyStore as API Key Service
Client->>API: GET /api/v1/jobs/123/sessions
Note over Client,API: Header: X-API-Key: 3v8x2j9kL4m5nQ6...
API->>Auth: Verify key
Auth->>KeyStore: Validate API key
KeyStore-->>Auth: Key valid for account
Auth-->>API: req.uid set to account owner
API-->>Client: 200 JSON response
```
**DO NOT use** `Authorization: Bearer 3v8x2j9...` — This will fail!
```bash
# ✅ CORRECT
curl -H "X-API-Key: 3v8x2j9kL4m5nQ6..." \
https://api.agmission.com/api/v1/jobs/12345/sessions
# ❌ WRONG
curl -H "Authorization: Bearer 3v8x2j9kL4m5nQ6..." \
https://api.agmission.com/api/v1/jobs/12345/sessions
```
### Key Management
- **Create** new keys at `https://agmission.agnav.com/api-keys`
- **Rotate** keys by creating new ones and disabling old ones
- **Scope** keys by each Master account
- **Revoke** immediately if compromised
### Security Best Practices
1. **Never commit keys to version control** — Use environment variables or secrets manager
```bash
export AGMISSION_API_KEY="3v8x2j9kL4m5nQ6..."
curl -H "X-API-Key: $AGMISSION_API_KEY" https://api.agmission.com/...
```
2. **Use HTTPS only** — All API endpoints require HTTPS.
3. **Rotate keys quarterly** — Implement key rotation in your automation
4. **Monitor key usage** — Check activity logs for suspicious patterns
---
## API Endpoints
NOTE: `mappedArea_ha` is the mapped/planned area value for the job and is not derived by summing the `/areas` GeoJSON polygons.
### 1. List Sessions
**Endpoint**: `GET /api/v1/jobs/:jobId/sessions`
Returns one summary per uploaded flight log file.
**Parameters**:
- `jobId` (path) — Job ID (integer)
**Response** (200 OK):
```json
{
"jobId": 12345,
"clientId": "507f1f77bcf86cd799439055",
"clientName": "Fazenda São Paulo Ltda",
"assignedPilotId": "507f1f77bcf86cd799439033",
"assignedPilotName": "John Smith",
"assignedAircraftId": "507f1f77bcf86cd799439044",
"assignedAircraftName": "AT-802F",
"assignedAircraftTailNumber": "N1234AT",
"planAircraftName": "AT-802F",
"planAircraftTailNumber": "N1234AT",
"assignedDate": "2026-04-21T18:00:00Z",
"mappedArea_ha": 48.5,
"reportConfirmed": false,
"areaSize_ha": 48.5,
"coverage_ha": 45.2,
"overSprayed_pct": -6.80,
"appRate": 50,
"appRateUnit": "lit/ha",
"appRateConfirmed": null,
"sprayVolume": 2260,
"volumeUnit": "lit",
"useConfirmedVolume": false,
"actualSprayVolume": 2260,
"confirmedActualVolume": null,
"effectiveVolume": 2260,
"useCustomWeather": false,
"weather": null,
"data": [
{
"sessionId": "507f1f77bcf86cd799439011",
"fileName": "flight_20260422_001.log",
"startDateTime": "2026-04-22T09:00:00Z",
"endDateTime": "2026-04-22T11:30:00Z",
"totalFlightTime_s": 9000,
"totalSprayTime_s": 7200,
"totalTurnTime_s": 1800,
"totalSprayed_ha": 45.2,
"totalSprayMat": 2260,
"totalSprayMatUnit": "lit",
"avgSpraySpeed_ms": 39.5,
"sprayZoneName": "Field A North",
"sprayZoneArea_ha": 25.0,
"appRate": 50,
"appRateUnit": "lit/ha",
"flowController": "Ag-Flow UFC",
"sprayOnLag_s": 0.2,
"sprayOffLag_s": 0.15,
"pulsesPerLiter": 1800,
"files": [
{ "fileId": "507f1f77bcf86cd799439022", "name": "n5021813.t44" }
],
"sessionPilotName": "John Smith"
}
]
}
```
**Confirmed vs Fallback Values**:
When `reportConfirmed: true`, the applicator has manually confirmed spray records in Report Settings:
- `areaSize_ha`, `coverage_ha`, `appRate`, `confirmedActualVolume`, `weather` come from the report
- Otherwise, system-calculated fallbacks are used
Volume derivation for `/sessions` envelope:
- `sprayVolume`: planned estimate based on total spray area and app rate (`coverage_ha × appRate`)
- `actualSprayVolume`: total calculated from recorded application sessions
- `confirmedActualVolume`: value entered in Report Settings when confirmed
- `effectiveVolume`: `confirmedActualVolume` when `useConfirmedVolume=true`, otherwise `actualSprayVolume`
- `volumeUnit`: follows job unit system and material type (`lit`/`gal` for liquid, `kg`/`lb` for solid/dry)
---
### 2. Get Records (Paginated GPS Trace)
**Endpoint**: `GET /api/v1/jobs/:jobId/sessions/:fileId/records`
Streams raw GPS points with cursor-based pagination.
**Parameters**:
- `jobId` (path) — Job ID
- `fileId` (path) — Session/file ID
- `startingAfter` (query) — Cursor for pagination
- `limit` (query) — Records per page (default 500, max 2000)
- `interval` (query) — GPS thinning interval in seconds (float). Spray-state changes are always included.
- `interval=0` (query) — Explicitly disable thinning (same as omitting `interval`).
**Interval Decision Table (`/records`)**:
Assume `interval=5` seconds and records are processed in returned order.
| Previous Kept `gpsTime` | Current `gpsTime` | `sprayStat` Changed? | Keep Current Record? | Reason |
|---|---:|---|---|---|
| none | 100 | N/A | Yes | First record is always kept |
| 100 | 103 | No | No | Inside 5-second window |
| 100 | 103 | Yes | Yes | Spray-state transition is always preserved |
| 100 | 106 | No | Yes | Outside interval window (`106 - 100 >= 5`) |
| 106 | 109 | No | No | Inside 5-second window |
| any | any | any | Yes (all) | If `interval=0` (or omitted), thinning is bypassed |
**Example**: Fetch 500 records, every 5 seconds
```bash
curl "https://api.agmission.com/api/v1/jobs/12345/sessions/507f1f77.../records?limit=500&interval=5" \
-H "X-API-Key: 3v8x2j9..."
```
**Response** (200 OK):
```json
{
"data": [
{
"timeUtc": "2026-04-22T09:00:15Z",
"gpsTime": 1745312415,
"lat": 40.7128,
"lon": -74.0060,
"alt": 150.5,
"grSpeed": 39.8,
"heading": 180,
"sprayStat": 1,
"flowRateApplied": 48.5,
"appRateApplied": 49.3,
"windSpeed_kt": 6.22,
"windDir_deg": 225,
"temp_c": 22.5,
"humidity_pct": 65
}
],
"hasMore": true,
"startingAfter": "507f191e810c19729de8605f",
"endingBefore": "507f1f77bcf86cd799439011"
}
```
**Cursor field meanings:**
- `startingAfter` — pass as query param to get the next page
- `endingBefore` — pass as query param to get the previous page
- `hasMore: false` + no `startingAfter` means you have reached the last page
**API Response Field Descriptions (`/records`)**:
Envelope fields:
| Field | Type | Description |
|---|---|---|
| `data` | array | Per-point telemetry records for the page (after optional interval thinning). |
| `hasMore` | boolean | `true` when additional pages exist. Pass `startingAfter` to fetch the next page. |
| `startingAfter` | string \| undefined | Last record ID of this page — pass as `?startingAfter=` to get the next page. Present whenever `data` is non-empty. |
| `endingBefore` | string \| undefined | First record ID of this page — pass as `?endingBefore=` to get the previous page. Present whenever `data` is non-empty. |
**GPS fields** (per record in `data[]`):
| Field | Type | Unit | Description |
|---|---|---|---|
| `timeUtc` | string \| null | ISO 8601 UTC | GPS timestamp converted from `gpsTime`. |
| `gpsTime` | number \| null | epoch seconds | Raw GPS time as seconds since epoch. |
| `lat` | number \| null | decimal degrees | Latitude (WGS84). 7 decimal places. |
| `lon` | number \| null | decimal degrees | Longitude (WGS84). 7 decimal places. |
| `utmX` | number \| null | meters | UTM easting coordinate. 1 decimal place. |
| `utmY` | number \| null | meters | UTM northing coordinate. 1 decimal place. |
| `alt` | number \| null | meters | Altitude above sea level. 2 decimal places. |
| `grSpeed` | number \| null | m/s | Aircraft ground speed. 2 decimal places. |
| `heading` | number \| null | degrees | Aircraft heading (0360°). 2 decimal places. |
| `xTrack` | number \| null | meters | Cross-track error from the guidance line. 2 decimal places. |
| `lockedLine` | number \| null | — | Guidance line number locked by the autopilot. |
| `hdop` | number \| null | — | Horizontal dilution of precision — lower is better GPS geometry. 2 decimal places. |
| `satsIn` | number \| null | — | Encoded satellite/inside-area value. `0..99` = outside area (value is satellite count). `100..199` = inside area (satellites = `value - 100`). Example: `112` = inside area with 12 satellites; `17` = outside area with 17 satellites. |
| `tslu` | number \| null | seconds | Time since last GPS differential correction update. Measures staleness of DGPS correction signal. |
| `calcodeFreq` | number \| null | — | Raw calibration/frequency field from the spray controller. **30,00060,000**: frequency/RPM mode — true RPM = value 30,000. **< 20,000**: positive spray offset in decimeters. **> 60,000**: negative spray offset — stored value = 65,536 abs(offset). |
| `sprayStat` | number \| null | — | Spray state. **0** = spray OFF. **1** = spray ON, inside spray area (continuing record). **3** = spray ON, **first point of a new spray line** (start-of-line marker; boom IS active). **10** = spray ON, outside spray area. Any non-zero value = boom open. |
**Application data fields**:
| Field | Type | Unit | Description |
|---|---|---|---|
| `flowRateApplied` | number \| null | L/min | Actual spray system flow rate as measured. Raw value from controller (`lminApp`). |
| `flowRateRequired` | number \| null | L/min | Target flow rate set by the spray controller (`lminReq`). |
| `appRateRequired` | number \| null | L/ha or kg/ha | Planned/target application rate, computed with this priority: (1) file metadata app rate, (2) controller-reported required rate (`lhaReq`), (3) job plan app rate. Always in metric. |
| `appRateApplied` | number \| null | L/ha or kg/ha | Computed actual application rate. **null when `sprayStat` is 0 (spray off)**. Non-null for all spray-on states: `sprayStat` = 1 (on, inside area), 3 (start of line), or 10 (on, outside area). For liquid material: derived from flow rate, swath, and ground speed. For dry material: flow reading used directly. When no flow controller is fitted or no flow reading is present, the file metadata app rate is used. Always in metric. |
| `swathWidth` | number \| null | meters | Effective boom/swath width at this point. |
| `boomPressure_psi` | number \| null | PSI | Boom pressure reading. |
| `flowController` | string | — | Flow controller name from the session. Normalised to `'No FC'` when absent or set to `"none"` in the source file. Session constant — same value repeated on every record. |
| `sprayOnLag_s` | number \| null | seconds | Spray-on lag configured in the session. Session constant repeated per record. |
| `sprayOffLag_s` | number \| null | seconds | Spray-off lag configured in the session. Session constant repeated per record. |
| `pulsesPerLiter` | number \| null | — | Flow meter calibration constant from the session. Session constant repeated per record. |
| `rpm` | array \| null | — | RPM array from the spray controller. Interpretation differs between liquid and dry material types. |
**MET (weather) fields**:
| Field | Type | Unit | Description |
|---|---|---|---|
| `windSpeed_kt` | number \| null | knots | Wind speed (converted from m/s at output). 2 decimal places. |
| `windDir_deg` | number \| null | degrees | Wind direction (0360°). 1 decimal place. |
| `temp_c` | number \| null | °C | Air temperature. 1 decimal place. |
| `humidity_pct` | number \| null | % | Relative humidity. 1 decimal place. |
**FM fields** (only when `?fm=true` is included in the request — FM-enabled equipment only):
| Field | Type | Unit | Description |
|---|---|---|---|
| `sprayHeight_m` | number \| null | meters | Target spray height (AgDisp). |
| `driftX_m` | number \| null | meters | Lateral drift offset X (AgDisp). |
| `driftY_m` | number \| null | meters | Lateral drift offset Y (AgDisp). |
| `depositX_m` | number \| null | meters | Deposit offset X (AgDisp). |
| `depositY_m` | number \| null | meters | Deposit offset Y (AgDisp). |
| `radarAlt_m` | number \| null | meters | Radar altimeter reading. |
| `laserAlt_m` | number \| null | meters | Laser altimeter reading. |
> **`appRateApplied` is null when spray is off** (`sprayStat = 0`). All other values (1, 3, 10) are spray-on states and will carry a computed rate.
> **`sprayStat = 3`** is the **first sprayed point of a new spray line** — spray IS active at this record. It is written when the boom transitions from OFF to ON. Include `sprayStat = 3` records when computing spray coverage; exclude `sprayStat = 0` records only.
> **`satsIn` decoding**: inside-area is encoded as `+100`, not bitmask. Parse with: `inside = (satsIn >= 100)` and `satellites = inside ? (satsIn - 100) : satsIn`.
**Pagination**:
```bash
# Get next page
curl "https://api.agmission.com/api/v1/jobs/12345/sessions/507f1f77.../records?startingAfter=507f191e810c19729de8605f" \
-H "X-API-Key: 3v8x2j9kL4m5nQ6..."
```
**Use Cases**:
- **Power BI incremental refresh**: Use `startingAfter` to fetch only new records since last sync
- **Lightweight queries**: Use `interval=5` to reduce data volume by 5x
- **Parity testing**: Use `interval=0` for full-fidelity page comparisons
---
### 3. Get Spray Areas
**Endpoint**: `GET /api/v1/jobs/:jobId/areas`
Returns GeoJSON FeatureCollection of planned spray zones.
**Response** (200 OK):
```json
{
"type": "FeatureCollection",
"jobId": 12345,
"features": [
{
"type": "Feature",
"properties": {
"name": "North Field",
"type": "area",
"area_ha": 48.5,
"appRate": 50,
"appRateUnit": "lit/ha"
},
"geometry": {
"type": "Polygon",
"coordinates": [[
[-74.0060, 40.7128],
[-74.0050, 40.7128],
[-74.0050, 40.7118],
[-74.0060, 40.7118]
]]
}
},
{
"type": "Feature",
"properties": {
"name": "Exclude - Power Lines",
"type": "xcl"
},
"geometry": {
"type": "Polygon",
"coordinates": [[
[-74.0055, 40.7125],
[-74.0053, 40.7125],
[-74.0053, 40.7120]
]]
}
}
]
}
```
**Field Meanings**:
- `type: "area"` — Planned spray zone (will have appRate/unit)
- `type: "xcl"` — Exclusion zone (no-spray boundary, skipped fields)
- `area_ha` — Polygon area in hectares
- `appRateUnit` — Material unit string (`'lit/ha'`, `'oz/ac'`, etc.)
**Import to ArcGIS**:
```javascript
// JavaScript + ArcGIS JS API
const response = await fetch('https://api.agmission.com/api/v1/jobs/12345/areas', {
headers: { 'X-API-Key': apiKey }
});
const featureCollection = await response.json();
const layer = new FeatureLayer({
source: featureCollection.features,
objectIdField: 'OBJECTID',
fields: [...],
renderer: {...}
});
map.add(layer);
```
---
### 4. Trigger Export (Async)
**Endpoint**: `POST /api/v1/jobs/:jobId/export`
Initiates async generation of a bulk export.
```mermaid
stateDiagram-v2
[*] --> pending: POST /export returns 202
pending --> processing: async generation starts
processing --> ready: file written to disk
processing --> error: generation failed
ready --> [*]: 24h TTL expires
error --> [*]: TTL expires
```
Poll `GET /exports/:exportId` until `status: "ready"`, then call the download endpoint.
**Request Body**:
```json
{
"format": "csv",
"units": "metric",
"interval": null
}
```
**Parameters**:
- `format` (string) — `"csv"` or `"json"`
- `units` (string, optional) — `"metric"` (default) or `"us"`
- `interval` (number, optional) — GPS point thinning in seconds (float). Spray-state changes are always included.
- `fm` (boolean, optional) — `true` to include Flight Master/AgDisp FM fields (`sprayHeight_m`, `driftX_m`, `driftY_m`, `depositX_m`, `depositY_m`, `radarAlt_m`, `laserAlt_m`). Default `false`. Only applicable for customers with FM-enabled equipment.
**Bulk export interval behavior**:
- Records are exported in a stable, deterministic order per file.
- If `interval` is omitted or `null`, all points are exported.
- If `interval` is provided, points inside the same window are thinned out.
- Records where `sprayStat` changes are never removed by thinning.
- Thinning is applied independently per file stream (not across one global timeline).
- Set `interval=0` (or omit `interval`) for full-fidelity export on bulk endpoints.
**Interval Decision Table (Bulk Export)**:
Bulk export uses the same keep/skip rule as `/records`, but runs per file stream.
| Previous Kept `gpsTime` (same file) | Current `gpsTime` | `sprayStat` Changed? | Keep Current Record? | Reason |
|---|---:|---|---|---|
| none | 500 | N/A | Yes | First record in file is kept |
| 500 | 503 | No | No | Inside interval window |
| 500 | 503 | Yes | Yes | Spray transition event is preserved |
| 500 | 506 | No | Yes | Outside interval window |
**API and Field Descriptions (Bulk Export Endpoints)**:
`POST /api/v1/jobs/:jobId/export` response fields:
| Field | Type | Description |
|---|---|---|
| `exportId` | string | Export tracker ID used for polling and download. |
| `status` | string | Initial status (`pending`) or reused status when deduplicated. |
| `format` | string | `csv` or `json`. |
| `units` | string | `metric` or `us`. |
| `createdAt` | string | ISO UTC creation timestamp. |
| `reused` | boolean | Present when request deduplicates to an existing export. |
| `downloadUrl` | string | Present immediately if reused export is already `ready`. |
`GET /api/v1/exports/:exportId` response fields:
| Field | Type | Description |
|---|---|---|
| `exportId` | string | Export tracker ID. |
| `status` | string | `pending`, `processing`, `ready`, or `error`. |
| `format` | string | Export format. |
| `units` | string | Unit system used for exported values. |
| `createdAt` | string | ISO UTC creation timestamp. |
| `expiresAt` | string \| null | Expiration time for cleanup. |
| `error` | string \| null | Error detail when status is `error`. |
| `downloadUrl` | string | Present only when status is `ready`. |
`GET /api/v1/exports/:exportId/download` output:
| Item | Description |
|---|---|
| Body | Streamed CSV or JSON file bytes |
| `Content-Type` | `text/csv` or `application/geo+json` |
| `Content-Disposition` | Attachment filename with extension |
**End-to-End Example (Trigger -> Poll -> Download)**:
```bash
# 1) Trigger export
TRIGGER=$(curl -sS -X POST "https://api.agmission.com/api/v1/jobs/12345/export" \
-H "X-API-Key: 3v8x2j9..." \
-H "Content-Type: application/json" \
-d '{"format":"csv","units":"metric","interval":5}')
echo "$TRIGGER"
# 2) Extract exportId (jq recommended)
EXPORT_ID=$(echo "$TRIGGER" | jq -r '.exportId')
# 3) Poll until ready
while true; do
STATUS_JSON=$(curl -sS "https://api.agmission.com/api/v1/exports/${EXPORT_ID}" \
-H "X-API-Key: 3v8x2j9...")
STATUS=$(echo "$STATUS_JSON" | jq -r '.status')
echo "status=${STATUS}"
if [ "$STATUS" = "ready" ]; then
break
fi
if [ "$STATUS" = "error" ]; then
echo "$STATUS_JSON"
exit 1
fi
sleep 5
done
# 4) Download file
curl -L "https://api.agmission.com/api/v1/exports/${EXPORT_ID}/download" \
-H "X-API-Key: 3v8x2j9..." \
-o export_job_12345.csv
```
**Dedup Shortcut**:
- If trigger response returns `reused: true` and `status: "ready"`, skip polling and download immediately using returned `downloadUrl`.
**Operational Notes**:
- Export files are temporary and expire by TTL (default 24h).
- Re-running the same request in dedup window may return the existing export instead of creating a new one.
- For full-fidelity parity checks, omit `interval` in export; use `/records?interval=0` for page-level comparisons.
**Response** (202 Accepted):
```json
{
"exportId": "66f4a8c1...",
"status": "pending",
"format": "csv",
"units": "metric",
"createdAt": "2026-04-22T14:00:00Z"
}
```
**Status Codes**:
- `202` — Export created and queued
- `200` — Existing export reused (deduplication — same job/format/units within 5 minutes):
```json
{
"exportId": "66f4a8c1...",
"status": "ready",
"format": "csv",
"units": "metric",
"createdAt": "2026-04-22T14:00:00Z",
"reused": true,
"downloadUrl": "/api/v1/exports/66f4a8c1.../download"
}
```
- `429` — Rate limit exceeded (check `Retry-After` header)
- `409` — Invalid parameters
> **Deduplication**: If you POST the same `jobId + format + units` within 5 minutes, the server returns the existing export (HTTP 200) instead of creating a new one. When `reused: true` and `status: "ready"`, `downloadUrl` is included immediately — skip polling.
---
### 5. Poll Export Status
**Endpoint**: `GET /api/v1/exports/:exportId`
Check generation progress.
**Response** (200 OK — Pending):
```json
{
"exportId": "66f4a8c1...",
"status": "pending",
"format": "csv",
"units": "metric",
"createdAt": "2026-04-22T14:00:00Z",
"expiresAt": null
}
```
**Response** (200 OK — Ready):
```json
{
"exportId": "66f4a8c1...",
"status": "ready",
"format": "csv",
"units": "metric",
"createdAt": "2026-04-22T14:00:00Z",
"expiresAt": "2026-04-23T14:00:00Z",
"downloadUrl": "/api/v1/exports/66f4a8c1.../download"
}
```
**Response** (200 OK — Error):
```json
{
"exportId": "66f4a8c1...",
"status": "error",
"error": "Job has no app data to export",
"createdAt": "2026-04-22T14:00:00Z"
}
```
**Polling Best Practice**:
```python
import time
import requests
def poll_export(export_id, api_key, max_wait_seconds=600):
start = time.time()
while time.time() - start < max_wait_seconds:
response = requests.get(
f'https://api.agmission.com/api/v1/exports/{export_id}',
headers={'X-API-Key': api_key}
)
data = response.json()
if data['status'] == 'ready':
return data['downloadUrl']
if data['status'] == 'error':
raise Exception(f"Export failed: {data.get('error')}")
# Exponential backoff: 1s, 2s, 4s, ...
time.sleep(min(2 ** (time.time() - start) / 10, 30))
raise TimeoutError('Export generation timeout')
```
---
### 6. Download Export
**Endpoint**: `GET /api/v1/exports/:exportId/download`
Stream the ready file.
**Response** (200 OK):
```
Content-Type: text/csv (or application/geo+json)
Content-Disposition: attachment; filename="export_job12345_66f4a8c1.csv"
[Binary file stream]
```
**Examples**:
```bash
# Download as file
curl -X GET "https://api.agmission.com/api/v1/exports/66f4a8c1.../download" \
-H "X-API-Key: 3v8x2j9kL4m5nQ6..." \
-o "export_$(date +%Y%m%d).csv"
```
```python
# Python with requests
import requests
response = requests.get(
'https://api.agmission.com/api/v1/exports/66f4a8c1.../download',
headers={'X-API-Key': api_key},
stream=True
)
with open('export.csv', 'wb') as f:
for chunk in response.iter_content(8192):
f.write(chunk)
```
```javascript
// JavaScript / Node.js
fetch('https://api.agmission.com/api/v1/exports/66f4a8c1.../download', {
headers: { 'X-API-Key': apiKey }
})
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'export.csv';
a.click();
});
```
---
## Rate Limiting
Rate limits are enforced **per customer account** to keep the API stable and fair for all users.
### Current Limit
- **20 requests per minute** per account (all Data Export API endpoints combined)
### Response Headers
| Header | Meaning |
|---|---|
| `RateLimit-Limit: 20` | Max requests per account per window |
| `RateLimit-Remaining: 18` | Requests left in current window |
| `RateLimit-Reset: 1745353200` | Unix timestamp of window reset |
| `Retry-After: 45` | Seconds to wait before retrying (on 429) |
### Behavior on Limit Exceeded
- HTTP status: `429 Too Many Requests`
- Respect `Retry-After` before sending new requests
- Keep retry logic idempotent (safe to re-run requests after wait)
### Customer Integration Recommendations
1. Use one shared request queue per account/API key to avoid bursts.
2. Add exponential backoff with jitter for retries (especially on `429` and transient `5xx`).
3. For bulk data, prefer async exports (`POST /export` + poll status) over large volumes of `/records` calls.
4. During polling, use a conservative interval (for example every 5-10 seconds).
5. Cache stable responses (for example `/areas`) where possible.
### Practical Retry Flow
- If response is `200/202`: continue normally.
- If response is `429`: wait `Retry-After` seconds, then retry.
- If response is transient `5xx`: retry with capped exponential backoff.
- If response is `4xx` (except `429`): treat as request issue and fix parameters/auth first.
---
## Data Formats
### CSV Export Columns
All CSV exports include one row per GPS point. Job/session metadata is repeated on every row so the file can be loaded directly into Power BI, Snowflake, or any data warehouse without a join.
Column headers include a unit suffix when `units='us'` (e.g. `groundSpeed_mph` instead of `groundSpeed_ms`).
**Job/Session Metadata** (repeated on every row, no join required):
| Metric column | US column | Description |
|---|---|---|
| `jobId` | same | Numeric job identifier. |
| `orderNumber` | same | Customer purchase order number. |
| `jobName` | same | Job name as entered by the applicator. |
| `clientId` | same | Client account ID (the applicator's customer). |
| `clientName` | same | Client account name. |
| `sessionId` | same | Flight session/file ID. |
| `fileName` | same | Original log file name. |
| `pilotName` | same | Pilot name as recorded in the data file. |
**GPS columns**:
| Metric column | US column | Unit (metric / US) | Description |
|---|---|---|---|
| `timeUtc` | same | ISO 8601 UTC | GPS timestamp. |
| `gpsTime` | same | epoch seconds | Raw GPS epoch time. |
| `lat` | same | decimal degrees | Latitude (WGS84). |
| `lon` | same | decimal degrees | Longitude (WGS84). |
| `utmX` | same | meters | UTM easting. |
| `utmY` | same | meters | UTM northing. |
| `alt_m` | `alt_ft` | m / ft | Altitude. |
| `groundSpeed_ms` | `groundSpeed_mph` | m/s / mph | Ground speed. |
| `heading` | same | degrees | Aircraft heading (0360°). |
| `crossTrackError_m` | `crossTrackError_ft` | m / ft | Cross-track deviation from guidance line. |
| `lockedLine` | same | — | Guidance line number. |
| `hdop` | same | — | Horizontal dilution of precision. |
| `satsIn` | same | — | Encoded satellite count with inside-area offset. `0..99` = outside area, satellite count is raw value. `100..199` = inside area, satellite count = `value - 100`. |
| `tslu` | same | seconds | Time since last GPS differential correction. |
| `calcodeFreq` | same | — | Raw calibration/frequency field. 30,00060,000 = RPM (true RPM = value 30,000). < 20,000 = positive spray offset (dm). > 60,000 = negative offset (65,536 abs). |
| `sprayStat` | same | — | Spray state: 0 = off. 1 = on (inside area). 3 = on, first point of new spray line. 10 = on (outside area). Any non-zero value = boom open. |
**Application data columns**:
| Metric column | US column | Unit (metric / US) | Description |
|---|---|---|---|
| `flowRateApplied_Lmin` | `flowRateApplied_galMin` | L/min / gal/min | Actual spray flow rate. |
| `flowRateRequired_Lmin` | `flowRateRequired_galMin` | L/min / gal/min | Controller target flow rate. |
| `appRateRequired_Lha` | `appRateRequired_galAc` | L/ha / gal/ac | Planned application rate. |
| `appRateApplied_Lha` | `appRateApplied_galAc` | L/ha / gal/ac | Computed applied rate. Empty/null when spray is off (`sprayStat = 0`). |
| `swathWidth_m` | `swathWidth_ft` | m / ft | Effective boom/swath width. |
| `boomPressure_psi` | same | PSI | Boom pressure (same in both unit systems). |
| `flowController` | same | — | Flow controller name; `'No FC'` when absent. |
| `sprayOnLag_s` | same | seconds | Spray-on delay. Session constant. |
| `sprayOffLag_s` | same | seconds | Spray-off delay. Session constant. |
| `pulsesPerLiter` | same | — | Flow meter calibration constant. Session constant. |
| `rpm` | same | — | RPM array (JSON-serialised). Interpretation depends on material type. |
**MET (weather) columns**:
| Metric column | US column | Unit (metric / US) | Description |
|---|---|---|---|
| `windSpeed_kt` | `windSpeed_mph` | knots / mph | Wind speed. |
| `windDir_deg` | same | degrees | Wind direction (0360°). |
| `temp_c` | `temp_f` | °C / °F | Air temperature. |
| `humidity_pct` | same | % | Relative humidity. |
**FM columns** (only when `fm=true` was set on the export trigger request):
| Column | Unit | Description |
|---|---|---|
| `sprayHeight_m` | meters | Target spray height (AgDisp). |
| `driftX_m` | meters | Lateral drift offset X (AgDisp). |
| `driftY_m` | meters | Lateral drift offset Y (AgDisp). |
| `depositX_m` | meters | Deposit offset X (AgDisp). |
| `depositY_m` | meters | Deposit offset Y (AgDisp). |
| `radarAlt_m` | meters | Radar altimeter reading. |
| `laserAlt_m` | meters | Laser altimeter reading. |
**US unit conversion factors** (applied at export time from canonical metric values):
| Metric field | US field | Conversion |
|---|---|---|
| `alt_m` | `alt_ft` | × 3.28084 |
| `groundSpeed_ms` | `groundSpeed_mph` | × 2.23694 |
| `crossTrackError_m` | `crossTrackError_ft` | × 3.28084 |
| `swathWidth_m` | `swathWidth_ft` | × 3.28084 |
| `flowRateApplied_Lmin` | `flowRateApplied_galMin` | × 0.264172 |
| `flowRateRequired_Lmin` | `flowRateRequired_galMin` | × 0.264172 |
| `appRateRequired_Lha` | `appRateRequired_galAc` | × 0.10694 |
| `appRateApplied_Lha` | `appRateApplied_galAc` | × 0.10694 |
| `windSpeed_kt` | `windSpeed_mph` | × 1.15078 |
| `temp_c` | `temp_f` | × 9/5 + 32 |
| `boomPressure_psi` | same | no conversion (already PSI) |
### JSON Export Format
Array of record objects, one per GPS point. Each record includes all fields (job metadata, GPS, rates, MET, etc.) with appropriate unit conversions:
```json
[
{
"jobId": 12345,
"sessionId": "507f1f77bcf86cd799439011",
"lat": 40.7128,
"lon": -74.0060,
"alt_m": 150.5,
"timeUtc": "2026-04-22T09:00:15Z",
"sprayStat": 1,
"grSpeed": 39.8,
"appRateApplied": 48.7,
"appRateRequired": 50,
"flowRateApplied": 45.3,
"flowRateRequired": 45.0,
"windSpeed_kt": 8.2,
"temp_c": 22.5
},
{
"jobId": 12345,
"sessionId": "507f1f77bcf86cd799439012",
"lat": 40.7129,
"lon": -74.0061,
"alt_m": 150.6,
"timeUtc": "2026-04-22T09:00:25Z",
"sprayStat": 1,
"grSpeed": 39.9,
"appRateApplied": 48.8,
"appRateRequired": 50,
"flowRateApplied": 45.4,
"flowRateRequired": 45.0,
"windSpeed_kt": 8.3,
"temp_c": 22.6
}
]
```
---
## Use Cases
### Use Case 1: Power BI Incremental Refresh
**Goal**: Update a Power BI dataset nightly with new GPS records.
**Solution**:
```python
import requests
from datetime import datetime, timedelta
def sync_to_powerbi(job_id, api_key):
# Get sessions
sessions = requests.get(
f'https://api.agmission.com/api/v1/jobs/{job_id}/sessions',
headers={'X-API-Key': api_key}
).json()
for session in sessions['data']:
file_id = session['sessionId']
# Paginate records
cursor = None
records = []
while True:
params = {'limit': 2000}
if cursor:
params['startingAfter'] = cursor
page = requests.get(
f'https://api.agmission.com/api/v1/jobs/{job_id}/sessions/{file_id}/records',
params=params,
headers={'X-API-Key': api_key}
).json()
records.extend(page['data'])
if not page.get('hasMore'):
break
cursor = page.get('startingAfter')
# Push to Power BI (REST API or XMLA endpoint)
# ...
```
### Use Case 2: ArcGIS Map Automation
**Goal**: Update ArcGIS Online layer with spray area boundaries.
```javascript
const job_id = 12345;
const api_key = '3v8x2j9kL4m5nQ6...';
// Fetch areas
const areaResponse = await fetch(
`https://api.agmission.com/api/v1/jobs/${job_id}/areas`,
{ headers: { 'X-API-Key': api_key } }
);
const areas = await areaResponse.json();
// Convert to Feature Service format
const features = areas.features.map(feature => ({
geometry: feature.geometry,
attributes: {
name: feature.properties.name,
type: feature.properties.type,
area_ha: feature.properties.area_ha
}
}));
// Add to ArcGIS layer via REST API
const updateResponse = await fetch(
'https://services.arcgis.com/.../updates',
{
method: 'POST',
body: new URLSearchParams({ features: JSON.stringify(features), token: agolToken })
}
);
```
### Use Case 3: Nightly Data Warehouse Load
**Goal**: Daily batch load all jobs' data into a data lake (S3, Snowflake, etc.).
```bash
#!/bin/bash
API_KEY="3v8x2j9kL4m5nQ6..."
JOBS=(12345 12346 12347)
S3_BUCKET="s3://company-spray-data"
DATE=$(date +%Y%m%d)
for job_id in "${JOBS[@]}"; do
echo "Exporting job $job_id..."
# Trigger export
export_id=$(curl -s -X POST "https://api.agmission.com/api/v1/jobs/${job_id}/export" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"format":"csv","units":"metric"}' \
| jq -r '.exportId')
# Poll until ready
while true; do
status=$(curl -s -X GET "https://api.agmission.com/api/v1/exports/${export_id}" \
-H "X-API-Key: ${API_KEY}" \
| jq -r '.status')
[ "$status" = "ready" ] && break
sleep 5
done
# Download and upload to S3
curl -s -X GET "https://api.agmission.com/api/v1/exports/${export_id}/download" \
-H "X-API-Key: ${API_KEY}" \
| aws s3 cp - "${S3_BUCKET}/spray_data/job${job_id}/data_${DATE}.csv"
echo "Completed: job $job_id${S3_BUCKET}/spray_data/job${job_id}/data_${DATE}.csv"
done
```
---
## Error Handling
### Error Response Format
All errors follow this structure:
```json
{
"error": {
".tag": "error_constant"
}
}
```
### Common HTTP Status Codes
| Code | Condition | Solution |
|---|---|---|
| 200 | Success | — |
| 202 | Export accepted (async) | Poll `/exports/:exportId` for completion |
| 400 | Bad request (invalid params) | Check endpoint docs for required fields |
| 401 | Invalid/missing API key | Verify `X-API-Key` header is present and valid |
| 404 | Resource not found | Check jobId, exportId, fileId exist and belong to your account |
| 409 | Conflict (e.g., invalid format) | Check format is `"csv"` or `"json"` |
| 429 | Rate limit exceeded | Wait `Retry-After` seconds, then retry with backoff |
| 500 | Server error | Retry with exponential backoff; contact support if persists |
### Example: Handling 429 Rate Limit
```python
import time
import requests
def request_with_backoff(url, api_key, max_retries=3):
for attempt in range(max_retries):
response = requests.get(
url,
headers={'X-API-Key': api_key}
)
if response.status_code == 429:
retry_after = int(response.headers.get('Retry-After', 60))
print(f"Rate limited. Waiting {retry_after} seconds...")
time.sleep(retry_after)
continue
response.raise_for_status()
return response.json()
raise Exception("Max retries exceeded")
```
---
## Support & SLAs
### Support Channels
| Channel | Notes |
|---|---|
| **Email**: `support@agnav.com` | 8:30am-4:30pm ET, Toronto, CA |
| **Phone**: 1-800-AGNAV-11 | 8:30am-4:30pm ET, Toronto, CA |
### API SLA
- **Availability**: 99.5% monthly uptime
- **Rate limit quota**: 20 requests/min per account
- **Export timeout**: 1 hour max generation time
- **File retention**: 24 hours after ready
- **Data accuracy**: ±0.5% for area/volume calculations
<!-- ### Status & Maintenance
- **Status Page**: `https://status.agmission.com`
- **Maintenance windows**: Tuesdays 2-4 AM ET (announced 7 days prior)
- **Incident response**: PagerDuty escalation, max 15-min response -->
### API Versioning
Current version: **v1**
- Breaking changes will be announced 90 days in advance
- Deprecation warnings via response headers: `Deprecation: true`
- Version support policy: At least 3 versions maintained simultaneously
---
## Appendix: Code Examples
### cURL Examples
```bash
# List sessions
curl -X GET https://api.agmission.com/api/v1/jobs/12345/sessions \
-H "X-API-Key: 3v8x2j9kL4m5nQ6..." \
-H "Accept: application/json"
# Get records with thinning
curl "https://api.agmission.com/api/v1/jobs/12345/sessions/507f1f77.../records?interval=5&limit=1000" \
-H "X-API-Key: 3v8x2j9kL4m5nQ6..."
# Get records without thinning (full-fidelity troubleshooting)
curl "https://api.agmission.com/api/v1/jobs/12345/sessions/507f1f77.../records?limit=1000&interval=0" \
-H "X-API-Key: 3v8x2j9kL4m5nQ6..."
# Trigger CSV export
curl -X POST https://api.agmission.com/api/v1/jobs/12345/export \
-H "X-API-Key: 3v8x2j9kL4m5nQ6..." \
-H "Content-Type: application/json" \
-d '{"format":"csv","units":"metric"}'
```
### JavaScript / Node.js
```javascript
const apiKey = '3v8x2j9kL4m5nQ6...';
async function fetchSessions(jobId) {
const response = await fetch(`https://api.agmission.com/api/v1/jobs/${jobId}/sessions`, {
headers: { 'X-API-Key': apiKey }
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
}
async function exportAndDownload(jobId) {
// Trigger export
const exportRes = await fetch(`https://api.agmission.com/api/v1/jobs/${jobId}/export`, {
method: 'POST',
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ format: 'csv', units: 'metric' })
});
const { exportId } = await exportRes.json();
// Poll for ready
let status = 'pending';
while (status !== 'ready') {
const statusRes = await fetch(`https://api.agmission.com/api/v1/exports/${exportId}`, {
headers: { 'X-API-Key': apiKey }
});
({ status } = await statusRes.json());
if (status !== 'ready') await new Promise(r => setTimeout(r, 5000));
}
// Download
return fetch(`https://api.agmission.com/api/v1/exports/${exportId}/download`, {
headers: { 'X-API-Key': apiKey }
});
}
```
---
**Contact**: AgMission Team - AG-NAV Inc.
**Email**: `agm_admin@agnav.com` or `support@agnav.com`
**Last Updated**: May 11, 2026
**Next Review**: December, 2026