# GCX FSHS Field Reports Integration API

Version: 1.0  
Production base URL: `https://monitor.gcxapps.net/api/backend/integration/field-reports`

This read-only API exposes every report type submitted from the GCX FSHS field application:

- delivery records, including supplier, quantities, unit breakdown and required quality checks;
- anonymous See Something, Say Something reports, including consented location and evidence metadata;
- authenticated application issue reports submitted by field officers.

## Authentication

Generate a key in **Admin > API Management > Generate Key** and select the `field_reports.read` scope. The raw key is shown once.

Send the key on every request:

```http
X-API-Key: YOUR_GENERATED_KEY
Accept: application/json
```

Keys are stored as one-way hashes, can be revoked from the same Admin page, and are rate limited. Never place a key in browser code, a mobile application, a URL, source control, screenshots or shared logs.

The default limit is 60 requests per minute per API key, shared by all protected integration endpoints. Revocation takes effect on the next request. Field officers cannot generate or list integration keys.

## Endpoints

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/summary` | Counts and latest update time for every feed |
| `GET` | `/deliveries` | Delivery records submitted by field officers |
| `GET` | `/anonymous-reports` | Anonymous reports and consented locations |
| `GET` | `/issue-reports` | Application issue reports |
| `GET` | `/media/anonymous/{id}` | Evidence attached to an anonymous report |
| `GET` | `/media/issue/{id}` | Screenshot attached to an issue report |
| `GET` | `/media/delivery-photo/{id}` | Delivery photo evidence |
| `GET` | `/media/delivery-voice/{id}` | Delivery voice note |

## Pagination

Feed endpoints use cursor pagination. The maximum page size is 200.

```http
GET /deliveries?limit=100
```

The response contains `has_more` and `next_cursor`. Pass `next_cursor` unchanged to retrieve the next page:

```http
GET /deliveries?limit=100&cursor=CURSOR_FROM_PREVIOUS_RESPONSE
```

Do not construct or modify cursors. A complete pull ends only when `has_more` is `false`.

```json
{
  "feed": "deliveries",
  "generated_at": "2026-10-15T12:00:00+00:00",
  "filters": {},
  "has_more": true,
  "next_cursor": "opaque-cursor",
  "items": []
}
```

## Incremental Synchronization

Each item has a stable `id`, an `updated_at` timestamp and a `fingerprint`. For reliable synchronization:

1. Request records using `updated_since` set slightly before the last successful checkpoint.
2. Traverse every page until `has_more` is `false`.
3. Upsert by `id` and skip processing when the stored fingerprint is unchanged.
4. Advance the checkpoint only after the complete traversal succeeds.
5. Treat retries as safe and idempotent.

Example:

```http
GET /deliveries?updated_since=2026-10-15T00:00:00Z&limit=200
```

## Filters

All values must be URL encoded.

| Filter | Feeds | Description |
| --- | --- | --- |
| `limit` | all | 1 to 200, default 100 |
| `cursor` | all | Opaque cursor from the previous response |
| `updated_since` | all | Inclusive ISO-8601 update timestamp |
| `updated_until` | all | Inclusive ISO-8601 update timestamp |
| `date_from` | all | Inclusive report or delivery date |
| `date_to` | all | Inclusive report or delivery date |
| `user_id` | deliveries, issue reports | Field officer ID |
| `plan_period_id` | deliveries | Local plan-period ID, including 2026/2027 Term 1 |
| `order` | all | `asc` (default) or `desc`; use the same value on every page |
| `school_id` | deliveries | School ID |
| `commodity_id` | deliveries | Commodity ID |
| `supplier_id` | deliveries | Supplier ID |
| `region` | deliveries, anonymous reports | Exact region name |
| `status` | deliveries, issue reports | Review or issue status |

## Delivery Record

```json
{
  "id": "delivery:2185",
  "record_type": "delivery",
  "source_id": 2185,
  "client_submission_id": "device-generated-uuid",
  "plan_period": { "id": 2, "label": "2025 Term 1", "academic_year": "2025", "term": "1" },
  "school": { "id": 194, "code": "SCH-194", "name": "Example School", "region": "Ashanti", "district": "Example District" },
  "commodity": { "id": 2, "code": "RICE", "name": "Rice", "unit": "kg" },
  "supplier": { "id": 63, "code": "SUP-063", "name": "Example Supplier" },
  "field_officer": { "id": 12, "name": "Field Officer" },
  "delivery_date": "2026-04-02",
  "expected_quantity_before": "144.226",
  "actual_quantity": "144.000",
  "remaining_quantity_after": "0.226",
  "base_unit_total": "14400.000",
  "units": [],
  "quality": { "status": "NO_ISSUES", "has_issues": false, "issues": [] },
  "review_status": "OK",
  "is_flagged": false,
  "flag_reason": null,
  "notes": null,
  "voice_transcript": null,
  "ocr_text": null,
  "photo_meta": null,
  "evidence": [],
  "created_at": "2026-04-02T13:52:41+00:00",
  "updated_at": "2026-04-02T13:52:41+00:00",
  "fingerprint": "sha256-hex-value"
}
```

Quantity fields are decimal strings and must not be parsed as binary floating point when exact reconciliation matters. `quality.status` is `NO_ISSUES`, `ISSUES_REPORTED`, or `NOT_RECORDED` for legacy records.

Each `units` entry contains `unit_name`, `unit_type`, `unit_size`, `unit_measure`, `quantity`, `base_unit_total`, and `notes`. The reported `actual_quantity` uses the commodity's contract unit; the base-unit total is stored separately for reconciliation. Do not add quantities across different commodity units.

`evidence` contains zero or more objects with `type` (`image` or `audio`) and an authenticated relative `url`. `photo_meta` preserves the recorded photo timestamp, coordinates, accuracy, hash, capture source and other captured metadata where supplied. These fields are null or empty when no evidence was captured or uploaded.

## Anonymous Report Record

Anonymous reports never include a user ID or user name.

```json
{
  "id": "anonymous-report:42",
  "record_type": "anonymous_report",
  "source_id": 42,
  "client_submission_id": "device-generated-uuid",
  "description": "Description supplied by the reporter",
  "location_consent": true,
  "location": {
    "latitude": 5.6037,
    "longitude": -0.187,
    "accuracy_metres": 18.2,
    "region": "Greater Accra",
    "address": "Reported address"
  },
  "media": {
    "type": "image",
    "url": "/api/backend/integration/field-reports/media/anonymous/42"
  },
  "captured_at": "2026-10-15T11:50:00+00:00",
  "created_at": "2026-10-15T12:00:00+00:00",
  "updated_at": "2026-10-15T12:00:00+00:00",
  "fingerprint": "sha256-hex-value"
}
```

`location` is `null` when the reporter declined location sharing or the device could not determine a position. Evidence URLs require the same API key header. Relative media URLs should be resolved against `https://monitor.gcxapps.net`. Historical reports may contain coordinates recorded before explicit consent was introduced; `location_consent` is false for those legacy records.

## Issue Report Record

Issue reports contain the field officer, affected page, description, workflow status and optional screenshot URL. Screenshot URLs require the same API key.

```json
{
  "id": "issue-report:8",
  "record_type": "issue_report",
  "source_id": 8,
  "client_submission_id": "device-generated-uuid",
  "field_officer": { "id": 12, "name": "Field Officer", "role": "FIELD_STAFF" },
  "page_url": "https://monitor.gcxapps.net/field-staff/new-delivery",
  "description": "The officer's description of the application issue.",
  "status": "OPEN",
  "media": { "type": "image", "url": "/api/backend/integration/field-reports/media/issue/8" },
  "created_at": "2026-10-15T10:30:00+00:00",
  "updated_at": "2026-10-15T10:30:00+00:00",
  "fingerprint": "sha256-hex-value"
}
```

Issue status is `OPEN`, `IN_PROGRESS`, `RESOLVED` or `CLOSED`. `media` is null when a screenshot was not attached.

## Summary

```http
GET /summary
```

```json
{
  "generated_at": "2026-10-15T12:00:00+00:00",
  "feeds": {
    "deliveries": { "total": 2185, "latest_updated_at": "2026-10-15T11:40:00+00:00" },
    "anonymous-reports": { "total": 42, "latest_updated_at": "2026-10-15T11:55:00+00:00" },
    "issue-reports": { "total": 8, "latest_updated_at": "2026-10-15T10:30:00+00:00" }
  }
}
```

## HTTP Responses

| Status | Meaning |
| --- | --- |
| `200` | Request completed |
| `401` | Key missing, invalid or revoked |
| `403` | Key lacks `field_reports.read` |
| `404` | Feed, record or media not found |
| `422` | Invalid cursor or filter |
| `429` | Rate limit exceeded; retry with backoff |
| `500` | Server error; retry with exponential backoff |

Use exponential backoff with jitter for `429` and `5xx` responses. Do not retry `401`, `403` or `422` until configuration or request parameters are corrected.

## Active Supplier Catalogue

The PWA supplier directory is synchronized server-side from the FSHS Integration API `suppliers` feed. Accounts with active portal access are imported, including suppliers without an approved application. Existing local supplier IDs are retained by matching the upstream integration ID, then an unambiguous supplier code or normalized legal name. Missing integrated suppliers are marked inactive only after a complete successful upstream traversal; delivery history is never deleted. Legacy local suppliers remain in the database. The upstream credential is stored only in the backend environment and is not part of this field-report API.

## Offline Records and Evidence

The field PWA keeps pending deliveries, anonymous reports, application issues and evidence on the device. It retries pending uploads when connectivity returns while the app is open, and when the officer reopens the app. A phone must have opened the application and loaded the required reference data online before it can record deliveries offline. Signing in requires connectivity. Pending deliveries and their evidence remain associated with the original officer account.

Submission IDs make retries idempotent. A successfully uploaded record becomes available to Admin and the read API from the same database. Anonymous report `captured_at` is the original device submission time; `created_at` is the server receipt time. Delivery photographs, voice notes, OCR text and photo metadata are uploaded after the delivery is saved. Their arrival changes the delivery's `updated_at` and fingerprint, so incremental consumers should upsert the record again.

Older evidence held only in a phone's local storage before this release is not automatically present on the server. The API returns stored server evidence only.

## Periods and Reconciliation

Keep each academic year and term separate. Use `plan_period_id` on the delivery feed to select one period. Creating a new plan period does not activate it or import allocations. Import and verify its allocation workbook before switching the active period.

This API does not emit deletion tombstones. Run periodic complete reconciliations if the receiving system must reflect administrator deletions. Only remove absent records after a complete, successful traversal of the same feed and filters. The published timestamps use UTC and second precision; overlap incremental checkpoints by at least one minute to catch same-second updates.
