Endpoint#
- POST
/api/syncUpload one batch of records. - PUT
/api/sync/photos/{client_uuid}Photo bytes, via the pre-signed URL returned for each accepted manifest.
curl -X POST "$FLORATRACE_URL/api/sync" \
-H "Authorization: Bearer ftd_demo_FT-BNK-02_7Kq2mX9pLw" \
-H "Content-Type: application/json" \
--data-binary @batch.jsonHeaders#
| Header | Value | Notes |
|---|---|---|
Authorization | Bearer ftd_… | Device token. Scoped to one device_id; revoked server-side when a phone is lost. |
Content-Type | application/json | UTF-8 JSON. Body limit 2 MB; photo bytes never travel in this request. |
batch_id | uuid (in body) | Idempotency key for the whole request. A retry re-sends the same batch_id. |
client_uuid | uuid (per record) | Idempotency key for each tree, measurement and photo. Minted once on the device. |
Request body#
client_uuid (photo → measurement), because the device never knows server ids. These tables are generated from the same zod schema the endpoint validates with (schema 2026-09-01).SyncBatch
Top-level object
schema_versionrequired"2026-09-01"
Contract version. The server rejects versions it doesn't speak.batch_idrequiredstring (uuid)
One per upload attempt series. Retrying the same batch re-sends the same id.device_idrequiredstring
Registered device, e.g. FT-BNK-02. Must match the bearer token.matches ^FT-[A-Z]{3}-\d{2}$app_versionrequiredstring
FloraTrace app version, e.g. 1.0.9 (5).min length 1max length 32sent_atrequiredstring (datetime)
Device clock at send time. Skew over 10 min is rejected.treesoptionalobject[]
Newly tagged trees (max 500).max 500 itemsdefault []measurementsoptionalobject[]
Field measurements (max 2000).max 2000 itemsdefault []photosoptionalobject[]
Photo manifests: hashes and sizes only (max 2000).max 2000 itemsdefault []
trees[]
Newly tagged trees · max 500
client_uuidrequiredstring (uuid)
Generated on the device when the record is created. The idempotency key: never reused, never regenerated on retry.qr_coderequiredstring
Printed tag, e.g. FT-BNK-000123. Measurements reference the tree by this code.matches ^FT-[A-Z]{3}-\d{6}$site_idrequired"bnk" | "pur" | "jhg"
Site the tree belongs to.plot_idrequiredstring
Permanent sample plot, e.g. pur-p2.matches ^[a-z]{3}-p\d{1,2}$species_coderequiredstring
Species code from the site's species list, e.g. SR (Sal).matches ^[A-Z]{2,3}$planted_onrequiredstring (date)
Planting date (YYYY-MM-DD).latrequirednumber
Latitude of the tree, WGS 84.-90 – 90lngrequirednumber
Longitude of the tree, WGS 84.-180 – 180gps_accuracy_mrequirednumber
Horizontal accuracy of the fix in metres. Above 10 m is flagged.0 – 500statusrequired"alive" | "dead" | "replaced" | "missing"
Tree status at tagging.planter_teamrequiredstring
Field team that planted and tagged the tree.min length 1max length 80farmeroptionalstring
Landholder. Private: never shown on public pages.max length 120
measurements[]
One field visit each · max 2000
client_uuidrequiredstring (uuid)
Generated on the device when the record is created. The idempotency key: never reused, never regenerated on retry.qr_coderequiredstring
Tree being measured: in this batch or already known to the server.matches ^FT-[A-Z]{3}-\d{6}$roundrequired"R0" | "R1" | "R2" | "R3"
Monitoring round.measured_atrequiredstring (datetime)
When the measurement was taken, ISO 8601 with offset.height_cmrequirednumber | null
Height in cm. Null if the tree is dead or missing.0 – 4,000rcd_mmrequirednumber | null
Root-collar diameter in mm.0 – 1,500dbh_cmrequirednumber | null
Diameter at breast height (130 cm), once the tree reaches it.0 – 300canopy_cover_pctrequirednumber | null
Canopy cover over the tree's spacing cell, 0–100.0 – 100healthrequired"healthy" | "stressed" | "diseased" | "dead"
Visual health class.mortality_causerequired"termite" | "waterlogging" | "grazing" | "drought" | "unknown" | null
Required when health is dead.photo_sha256requiredstring | null
SHA-256 (hex) of the photo, computed on capture. Bytes follow separately.matches ^[a-f0-9]{64}$photo_latrequirednumber | null
Where the photo was taken (EXIF).-90 – 90photo_lngrequirednumber | null
Where the photo was taken (EXIF).-180 – 180gps_accuracy_mrequirednumber
Accuracy of the fix at measurement time. Above 10 m is flagged.0 – 500collectorrequiredstring
Enumerator who took the measurement.min length 1max length 80notesoptionalstring
Free-text field notes.max length 1000default ""
photos[]
Manifests only · max 2000
client_uuidrequiredstring (uuid)
Generated on the device when the record is created. The idempotency key: never reused, never regenerated on retry.measurement_client_uuidrequiredstring (uuid)
The measurement this photo documents.sha256requiredstring
SHA-256 (hex) of the exact bytes that will be uploaded.matches ^[a-f0-9]{64}$bytesrequiredinteger
Size of the file in bytes (max 15 MB).1 – 1,57,28,640mimerequired"image/jpeg" | "image/webp" | "image/heic"
Content type of the upload.captured_atrequiredstring (datetime)
Capture time from EXIF.
{
"schema_version": "2026-09-01",
"batch_id": "0b6f3c1e-2a4d-4e8f-9b1c-7d2e5f8a9c10",
"device_id": "FT-BNK-02",
"app_version": "1.0.9 (5)",
"sent_at": "2026-09-28T04:10:00.000Z",
"trees": [
{
"client_uuid": "5f0c2a8e-7d1b-4c3e-9a64-1b2c3d4e5f60",
"qr_code": "FT-BNK-000901",
"site_id": "bnk",
"plot_id": "bnk-p1",
"species_code": "SR",
"planted_on": "2026-09-20",
"lat": 23.4012,
"lng": 86.9876,
"gps_accuracy_m": 3.8,
"status": "alive",
"planter_team": "Planting group A"
}
],
"measurements": [
{
"client_uuid": "8a1e4b2c-3d5f-4e6a-8b7c-9d0e1f2a3b41",
"qr_code": "FT-BNK-000901",
"round": "R0",
"measured_at": "2026-09-20T08:12:00+05:30",
"height_cm": 46,
"rcd_mm": 6.2,
"dbh_cm": null,
"canopy_cover_pct": 4,
"health": "healthy",
"mortality_cause": null,
"photo_sha256": null,
"photo_lat": null,
"photo_lng": null,
"gps_accuracy_m": 3.8,
"collector": "Field officer N1",
"notes": "Replacement planting after R2 mortality."
},
{
"client_uuid": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e62",
"qr_code": "FT-BNK-000017",
"round": "R2",
"measured_at": "2026-03-14T09:05:00+05:30",
"height_cm": 182,
"rcd_mm": 24.5,
"dbh_cm": 1.1,
"canopy_cover_pct": 38,
"health": "healthy",
"mortality_cause": null,
"photo_sha256": null,
"photo_lat": null,
"photo_lng": null,
"gps_accuracy_m": 4.1,
"collector": "Field officer N1",
"notes": ""
},
{
"client_uuid": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f73",
"qr_code": "FT-BNK-000017",
"round": "R3",
"measured_at": "2026-09-21T10:40:00+05:30",
"height_cm": 176,
"rcd_mm": 26.1,
"dbh_cm": 1.4,
"canopy_cover_pct": 41,
"health": "stressed",
"mortality_cause": null,
"photo_sha256": "9650f149d622055c4b495e65e9148ebfc5e61b1bb00e83538e76afe48c496eaf",
"photo_lat": 23.40158,
"photo_lng": 86.98712,
"gps_accuracy_m": 12.4,
"collector": "Field officer N1",
"notes": "Leader browsed by cattle; lateral shoots healthy."
}
],
"photos": [
{
"client_uuid": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a84",
"measurement_client_uuid": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f73",
"sha256": "9650f149d622055c4b495e65e9148ebfc5e61b1bb00e83538e76afe48c496eaf",
"bytes": 134,
"mime": "image/jpeg",
"captured_at": "2026-09-21T10:41:00+05:30"
}
]
}Response#
| Field | Type | Meaning |
|---|---|---|
batch_id | uuid | Echo of the request. |
received_at | datetime | Server time the batch was stored. |
status | "accepted" | "partial" | partial when at least one record was rejected. |
accepted | { trees, measurements, photos } | Records inserted by this call. |
duplicates | { kind, client_uuid }[] | Already stored with identical content. Not inserted again; safe to drop from the queue. |
rejected | { kind, client_uuid, code, message }[] | conflict, unknown_tree, unknown_measurement, hash_mismatch. |
warnings | { kind, client_uuid, code, message }[] | Stored, but flagged for QA: gps_poor (> 10 m), height_drop (below an earlier round in the batch). |
uploads | { client_uuid, sha256, url, method, expires_at }[] | One pre-signed PUT per accepted photo manifest, valid 15 minutes. |
{
"batch_id": "0b6f3c1e-2a4d-4e8f-9b1c-7d2e5f8a9c10",
"received_at": "2026-09-28T04:10:00.000Z",
"status": "accepted",
"accepted": {
"trees": 1,
"measurements": 3,
"photos": 1
},
"duplicates": [],
"rejected": [],
"warnings": [
{
"kind": "measurement",
"client_uuid": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f73",
"code": "gps_poor",
"message": "GPS accuracy ±12.4 m on FT-BNK-000017 R3 is worse than 10 m."
},
{
"kind": "measurement",
"client_uuid": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f73",
"code": "height_drop",
"message": "FT-BNK-000017 R3: height 176 cm is below 182 cm in R2. Check for browsing or a misread."
}
],
"uploads": [
{
"client_uuid": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a84",
"sha256": "9650f149d622055c4b495e65e9148ebfc5e61b1bb00e83538e76afe48c496eaf",
"method": "PUT",
"url": "/api/sync/photos/e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a84?exp=1790569500000&sig=1c8a85a04124bb84a299f713b67088c1",
"expires_at": "2026-09-28T04:25:00.000Z"
}
]
}Status codes#
| Code | When | App should |
|---|---|---|
200 OK | Every record accepted or a duplicate. Also returned when an identical batch is replayed (Idempotent-Replayed: true). | Clear the batch from the queue. |
207 Multi-Status | Some records rejected; the rest were stored. | Clear accepted and duplicate records; surface rejected ones. |
400 Bad Request | Invalid JSON, schema violations (flattened as path → message), unsupported schema_version, or clock skew over 10 min. | Fix and re-send. Don't retry blindly. |
401 Unauthorized | Missing, malformed or revoked device token. | Re-register the device. |
409 Conflict | batch_id already used for a different payload. | Mint a new batch_id. |
413 Payload Too Large | Body over 2 MB. | Split into smaller batches. |
{
"error": "validation_failed",
"message": "8 field(s) failed validation.",
"issues": [
{
"path": "device_id",
"message": "Invalid string: must match pattern /^FT-[A-Z]{3}-\\d{2}$/"
},
{
"path": "trees.0.qr_code",
"message": "Invalid string: must match pattern /^FT-[A-Z]{3}-\\d{6}$/"
},
{
"path": "trees.0.gps_accuracy_m",
"message": "Too small: expected number to be >=0"
},
{
"path": "measurements.0.height_cm",
"message": "Too small: expected number to be >=0"
},
{
"path": "measurements.0.canopy_cover_pct",
"message": "Too big: expected number to be <=100"
},
{
"path": "measurements.1.photo_sha256",
"message": "Invalid string: must match pattern /^[a-f0-9]{64}$/"
},
{
"path": "measurements.1.collector",
"message": "Too small: expected string to have >=1 characters"
},
{
"path": "measurements.1.mortality_cause",
"message": "Required when health is dead."
}
]
}Idempotency#
client_uuid and the same content returns it under duplicates and never inserts it twice. The same client_uuid with different content is a conflict: a correction is a new record with a new uuid, because history is append-only.- 1Same batch_id, same body. The stored response is replayed with Idempotent-Replayed: true. Nothing is re-processed.
- 2Same batch_id, different body. 409 Conflict. The app must mint a new batch_id.
- 3New batch_id, records already stored. 200 with every record under duplicates; accepted counts are zero.
- 4Same client_uuid, changed content. That record is rejected with code conflict; the rest of the batch goes through (207).
{
"batch_id": "1c7a4d2f-3b5e-4f9a-8c2d-8e3f6a9b0d21",
"received_at": "2026-09-28T04:10:00.000Z",
"status": "accepted",
"accepted": {
"trees": 0,
"measurements": 0,
"photos": 0
},
"duplicates": [
{
"kind": "tree",
"client_uuid": "5f0c2a8e-7d1b-4c3e-9a64-1b2c3d4e5f60"
},
{
"kind": "measurement",
"client_uuid": "8a1e4b2c-3d5f-4e6a-8b7c-9d0e1f2a3b41"
},
{
"kind": "measurement",
"client_uuid": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e62"
},
{
"kind": "measurement",
"client_uuid": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f73"
},
{
"kind": "photo",
"client_uuid": "e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a84"
}
],
"rejected": [],
"warnings": [
{
"kind": "measurement",
"client_uuid": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f73",
"code": "height_drop",
"message": "FT-BNK-000017 R3: height 176 cm is below 182 cm in R2. Check for browsing or a misread."
}
],
"uploads": []
}Photo upload#
- Capture. The app computes the SHA-256 of the JPEG on the phone and stores it in the measurement (photo_sha256).
- Manifest. The batch carries a photos[] entry: client_uuid, measurement_client_uuid, sha256, bytes, mime. No image data.
- Pre-signed URL. For each accepted manifest the response returns a PUT URL valid for 15 minutes. Photos can wait for Wi-Fi.
- Verify. The server hashes the uploaded bytes and compares them to the manifest: a mismatch is a 422 and the upload is discarded. Re-uploading verified bytes is a no-op.
- Anchor. The verified hash is part of the record that is later rolled into the round's Merkle root.
PUT /api/sync/photos/e5f6a7b8-c9d0-4e1f-8a2b-3c4d5e6f7a84?exp=1790569500000&sig=1c8a85a04124bb84a299f713b67088c1
Content-Type: image/jpeg
<photo bytes>{
"ok": true,
"client_uuid": "e5f6a7b8-…",
"sha256": "9f2c…",
"verified_at": "2026-09-28T04:10:02.000Z",
"message": "SHA-256 verified. Photo stored."
}