Read water-quality test results as JSON. A key, a terminal, and you're done.
# Copy, paste, run. This works immediately.
curl -H "Authorization: Bearer segura_demo_public_2026" \
https://app.segura-water.com/api/v1/results
segura_demo_public_2026 is a published demo key. It serves a
synthetic dataset — photographs, GPS positions, compliance breaches,
multi-metal scans, failures — from a separate database holding no real
measurements. Every record carries isDemo: true and every
response carries "mode": "demo". Build and test an integration
against it before asking anyone for access.
curl -H "Authorization: Bearer $SEGURA_KEY" \
https://app.segura-water.com/api/v1/results > results.json
That's the whole API, more or less. What follows is filtering, formats, and the two things you must not get wrong.
The same data is available over
Model Context Protocol at
https://app.segura-water.com/mcp, so Claude, Cursor and anything
else that speaks MCP can read results directly. Add it as a custom connector
and sign in with a Segura account — no key to paste:
claude mcp add --transport http segura https://app.segura-water.com/mcp
An API key works there too, for scripts and CI where nobody is present to approve a browser prompt — and so does the demo key above, if you just want to see what it does. It is read-only, and it reads through the same code that serves this API, so the two cannot disagree about a compliance verdict.
Keys are issued by a Segura administrator — email
[email protected] and say what
you're integrating. A key looks like segura_xK3n…, is long-lived,
and is revoked rather than expiring. Send it as a bearer token on every request:
Authorization: Bearer segura_xK3n...
If a key leaks, tell us and we'll revoke it — a revoked key starts returning
401 revoked_key immediately. There is no self-service rotation yet.
Check measurement.qualifier before you use
measurement.value.
"0 ppb" and "we tested and found nothing" are different claims. When no peak is
found, value is null and qualifier is
not_detected — meaning the concentration is below what the method
can see, not that it is zero. Recording that as 0 would report clean
water on evidence that doesn't support it.
value is non-null only when qualifier is ok.
The corollary matters just as much: not_detected is an
answer, not a failure. measurement.error is null for it,
and the analysis's own account of where it searched — "No peak found at
−600 ± 90 mV" — is in measurement.note. Don't
queue those for a re-run; they're results.
| Qualifier | Meaning |
|---|---|
ok | Quantified. Use value. |
not_detected | Scan ran, analysis completed, no peak found. Below detection — not zero, and not a failure. note says where we looked. |
not_quantified | Peak found, but no concentration could be derived — usually no calibration for this analyte. |
error | The run or the analysis could not complete. See measurement.error. Worth re-running. |
no_result | Uploaded with no analysis attached. |
Two other things worth knowing. Units are always stated —
measurement.unit is "ppb"; never infer it.
And sample.name is free text typed by an operator
in the field, so it is not a reliable key to join on. If you need results to
match samples in your own system, talk to us about carrying your identifier.
Every result is judged against a regulatory limit and told you plainly:
"compliance": {
"standard": "WHO-2022",
"limit": 10,
"unit": "ppb",
"basis": "health",
"status": "exceeds",
"ratio": 1.97
}
below_detection is not a pass.
When nothing was found we report below_detection, not
within_limit. This method's detection floor is not known to sit
under every regulatory limit, so "we saw nothing" does not establish "under
10 ppb". Treating it as compliant would be a claim the measurement
doesn't support. Likewise no_limit means the standard is silent
on that metal — silence is not permission.
Pass ?standard= to pick the jurisdiction — WHO-2022
(default), US-EPA, EU-2020. The same 12 ppb lead
result exceeds the WHO guideline of 10 and sits within the EPA
action level of 15, which is exactly why the standard is always named in the
response.
Read basis before quoting a breach. health is a
toxicological guideline. action-level — the US EPA lead and copper
figures — is a 90th-percentile trigger for utility treatment obligations, and
applying it to a single sample is a category error.
aesthetic / indicator concern taste and
staining, not toxicity. The published limits are at
/api/v1/analytes.
# Just the breaches, judged against EU limits
curl -H "Authorization: Bearer $SEGURA_KEY" \
"https://app.segura-water.com/api/v1/results?exceeds=true&standard=EU-2020"
A Lead/Copper method measures two metals, so there is no single limit to quote.
Those results carry a compliance.perAnalyte array instead — each
metal against its own limit — and compliance.status becomes a
roll-up:
"compliance": {
"standard": "WHO-2022",
"limit": null, // no single limit applies
"status": "exceeds", // roll-up of the array below
"perAnalyte": [
{ "code": "Pb", "value": 14.8, "limit": 10, "status": "exceeds" },
{ "code": "Cu", "value": 890, "limit": 2000, "status": "within_limit" }
]
}
Every metal the method targets appears, including any that produced no peak.
The roll-up is within_limit only when every metal was
measured and passed — a scan that found lead under its limit but never
saw copper has not shown the water is free of copper, so it reads
not_assessed. If you only branch on
compliance.status, multi-metal results behave correctly without
you handling this case at all.
# Excel
curl -H "Authorization: Bearer $SEGURA_KEY" \
"…/api/v1/results?format=csv&limit=5000" -o results.csv
# QGIS, ArcGIS, Leaflet, mWater
curl -H "Authorization: Bearer $SEGURA_KEY" \
"…/api/v1/results?format=geojson" -o sites.geojson
CSV is RFC 4180 with a UTF-8 BOM so Excel opens it correctly, and puts
qualifier immediately before value — an empty value
cell is never left to be read as zero. GeoJSON is a FeatureCollection of
points; results with no GPS fix are omitted, and the number omitted is
reported in the file under a segura member, so a map is
never quietly mistaken for the whole dataset.
For when nobody wants the rows. Groups the whole matching set — not a page — by
cluster, supercluster, analyte or
method:
curl -H "Authorization: Bearer $SEGURA_KEY" \
"…/api/v1/summary?groupBy=cluster"
{
"groupBy": "cluster", "standard": "WHO-2022", "totalResults": 38,
"groups": [
{ "group": "Kisumu West — August", "results": 12,
"exceeding": 3, "withinLimit": 4, "notAssessed": 5,
"qualifiers": { "ok": 7, "not_detected": 5 },
"maxValue": 19.7, "unit": "ppb",
"firstMeasuredAt": "…", "lastMeasuredAt": "…" }
]
}
notAssessed counts results that were not judged against a
limit — below detection, no published limit, unknown analyte. They are kept out
of withinLimit deliberately: none of them is evidence of
compliance, and folding them in would inflate the passing count.
analyte is a resolved object, not a label. Filter and group on
analyte.code — never on provenance.testType, which is
the free text an operator saw and varies by method and range.
"analyte": {
"kind": "targeted", // targeted | survey | unknown
"code": "Pb",
"name": "Lead",
"cas": "7439-92-1", // map this onto your own test code
"targets": ["Pb"], // what the method looks for
"range": "low" // low | high | null
}
Three cases to handle. A multi-metal method (say Lead/Copper)
has targets: ["Pb","Cu"] and code: null — one
top-level number would be ambiguous across two metals, so the real answer is
the per-peak list in signal.detectedPeaks. A survey
scan has kind: "survey" and no targets; it looks for
whatever is present. And kind: "unknown" means no analyte is being
claimed for the record: either it predates method tracking, or the method that
produced it is not yet reportable in this vocabulary. Either way we don't know,
and we won't guess — so it will not answer ?analyte=.
The full vocabulary is at /api/v1/analytes,
so you can map our codes onto yours programmatically rather than from this page.
| Endpoint | |
|---|---|
GET /api/v1/results | List results, newest first. |
GET /api/v1/results/{sessionId} | One result. Add ?include=raw for the unprocessed traces. Demo records 404 here too unless you pass includeDemo=true. |
GET /api/v1/summary | Counts and exceedances, grouped by batch, analyte or method. |
GET /api/v1/results/{sessionId}/photo | The field photograph, if there is one. |
GET /api/v1/analytes | The analyte vocabulary — metals, CAS numbers, the methods that target them, and the regulatory limits applied. No key needed. |
GET /api/v1/health | Liveness. No key needed. |
GET /api/v1/openapi.json | The spec. Import it into Postman, or generate a client. |
/results| Parameter | |
|---|---|
cursor | Switches to sync mode and resumes from this position. Start with 0. See below. |
since | Only results measured at or after this ISO-8601 date/datetime. A domain filter — not a sync watermark. |
until | The far end of the range. An inverted range is a 400, not an empty page. |
standard | WHO-2022 (default), US-EPA, EU-2020. |
exceeds | true for breaches only. Filtered before paging, so total is an exact breach count and limit=1 returns a breach. |
format | json (default), csv, geojson. Exports allow limit up to 10000. |
cluster | Exact batch/folder name. |
supercluster | Every result whose cluster is filed under this campaign — one call for a whole town's survey instead of one per day. Case-insensitive. |
analyte | Every result whose method targets this metal. Pb or Lead — same query, and both catch the low-range, high-range and Lead/Copper methods too. Unknown values 400. |
method | One exact method, e.g. lead-low-asv-v1, when analyte is too broad. |
limit | Per page. Default 100, max 1000. |
offset | Skip this many, for paging. |
includeDemo | Include synthetic demo data. Off by default — leave it off for anything that stores results. |
# Everything measured since the 1st, as Lead, 500 at a time
curl -H "Authorization: Bearer $SEGURA_KEY" \
"https://app.segura-water.com/api/v1/results?since=2026-08-01&analyte=Lead&limit=500"
If you're replicating results rather than just looking at them, use
cursor. Start at 0, then keep passing back the
nextCursor you were last given:
curl -H "Authorization: Bearer $SEGURA_KEY" \
"https://app.segura-water.com/api/v1/results?cursor=0&limit=500"
# -> { "results": [...], "nextCursor": "MjAyNi0...", "hasMore": true, "total": 1284 }
# Repeat with that nextCursor until hasMore is false. Then keep the token
# and poll with it — new results append to the same feed.
Don't build a sync on since.
since filters on measuredAt, which is set by the
phone, not by us. A device that was offline for a fortnight uploads a record
already dated two weeks ago — below any high-water mark you've
advanced past. You'd never receive it, and nothing would tell you a result
was missing.
cursor orders by updatedAt, which we stamp on every
write, so a late upload lands at the end of the feed where you'll
pick it up. Corrections do the same. Keep since for domain
questions like "everything measured in August".
The feed is at-least-once. A record can come back — a phone
re-syncing, a correction, a re-file on our side. De-duplicate on
sessionId and treat a repeat as an update to what you already
hold. In sync mode total tells you how much backlog is left, and
offset doesn't apply.
{
"total": 1284,
"count": 2,
"limit": 100,
"offset": 0,
"results": [
{
"sessionId": "a7f3c210-...",
"measuredAt": "2026-08-14T11:04:22+03:00",
"receivedAt": "2026-08-14 09:31:06",
"updatedAt": "2026-08-14 09:31:06", // what cursor orders by
"sample": { "name": "Borehole 4", "cluster": "Kisumu Aug", "supercluster": "Kisumu", "operator": "A. Otieno" },
"location": { "latitude": -0.0917, "longitude": 34.768, "description": "Kisumu West" },
"analyte": { "kind": "targeted", "code": "Pb", "name": "Lead",
"cas": "7439-92-1", "targets": ["Pb"], "range": "low" },
"measurement": {
"value": 12.4,
"unit": "ppb",
"qualifier": "ok",
"quantifiedBy": "height",
"dilutionFactor": 1,
"note": null,
"error": null
},
"signal": {
"peakFound": true,
"peakPotential_mV": -601,
"peakCurrent_nA": 84.2,
"peakArea_nAmV": 1503.8,
"peakWidth_mV": 41.2,
"signalToNoise": 18.4
},
"instrument": { "readerDeviceId": "ES-0142", "phoneModel": "SM-A155F" },
"provenance": { "testType": "Lead — Low Range (0–100 ppb)", // display label; don't filter on it
"templateId": "lead-low-asv-v1", "templateName": "Lead Test — Low Range",
"appVersion": "0.5.2", "appBuild": "412",
"uploadedBy": "[email protected]" },
"isDemo": false
},
{
"sessionId": "b1d8e004-...",
"measuredAt": "2026-08-14T10:12:55+03:00",
"receivedAt": "2026-08-14 09:31:06",
"updatedAt": "2026-08-14 09:31:06", // what cursor orders by
"sample": { "name": "Borehole 5", "cluster": "Kisumu Aug", "supercluster": "Kisumu", "operator": "A. Otieno" },
"location": { "latitude": -0.0904, "longitude": 34.771, "description": "Kisumu West" },
"analyte": { "kind": "targeted", "code": "Pb", "name": "Lead",
"cas": "7439-92-1", "targets": ["Pb"], "range": "low" },
"measurement": {
"value": null, // null, NOT 0 —
"unit": "ppb",
"qualifier": "not_detected", // nothing found, below detection
"quantifiedBy": null,
"dilutionFactor": 1,
"note": "No peak found at -600 ± 90 mV", // where we looked
"error": null // not a failure — don't re-run it
},
"signal": { "peakFound": false, "peakPotential_mV": null, "peakCurrent_nA": null,
"peakArea_nAmV": null, "peakWidth_mV": null, "signalToNoise": 3.1 },
"instrument": { "readerDeviceId": "ES-0142", "phoneModel": "SM-A155F" },
"provenance": { "testType": "Lead — Low Range (0–100 ppb)", // display label; don't filter on it
"templateId": "lead-low-asv-v1", "templateName": "Lead Test — Low Range",
"appVersion": "0.5.2", "appBuild": "412",
"uploadedBy": "[email protected]" },
"isDemo": false
}
]
}
Errors are { "error": "code", "message": "..." }. Branch on
error; the message is for humans and may change.
| Status | Code | |
|---|---|---|
| 400 | invalid_query | A parameter was malformed. The message says which. |
| 401 | missing_key | No Authorization header. |
| 401 | invalid_key | Key not recognised. |
| 401 | revoked_key | Key was withdrawn. Message says when. |
| 404 | not_found | No result with that sessionId. |
| 429 | — | Rate limited. Back off and retry. |
The API is read-only — there is no way to write or change a
result through it. Rate limit is 120 requests per minute per key. Results are
returned as stored; a result that is later re-analysed on the dashboard keeps
its original sessionId and its instrument value here.
If you need something this doesn't do — a push/webhook instead of polling, CSV instead of JSON, your own sample identifiers carried through, or a field we're not exposing — say so. This surface is small on purpose and grows on request.