Segura API

Read water-quality test results as JSON. A key, a terminal, and you're done.

Try it now — no key needed

# 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.

With a real key

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.

Using this from an AI assistant

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.

Getting a key

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.

Reading a result correctly

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.

QualifierMeaning
okQuantified. Use value.
not_detectedScan ran, analysis completed, no peak found. Below detection — not zero, and not a failure. note says where we looked.
not_quantifiedPeak found, but no concentration could be derived — usually no calibration for this analyte.
errorThe run or the analysis could not complete. See measurement.error. Worth re-running.
no_resultUploaded with no analysis attached.

Two other things worth knowing. Units are always statedmeasurement.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.

Compliance

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"

Multi-metal scans

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.

Spreadsheets and maps

# 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.

Summary

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.

Analytes

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.

Endpoints

Endpoint
GET /api/v1/resultsList 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/summaryCounts and exceedances, grouped by batch, analyte or method.
GET /api/v1/results/{sessionId}/photoThe field photograph, if there is one.
GET /api/v1/analytesThe analyte vocabulary — metals, CAS numbers, the methods that target them, and the regulatory limits applied. No key needed.
GET /api/v1/healthLiveness. No key needed.
GET /api/v1/openapi.jsonThe spec. Import it into Postman, or generate a client.

Filters on /results

Parameter
cursorSwitches to sync mode and resumes from this position. Start with 0. See below.
sinceOnly results measured at or after this ISO-8601 date/datetime. A domain filter — not a sync watermark.
untilThe far end of the range. An inverted range is a 400, not an empty page.
standardWHO-2022 (default), US-EPA, EU-2020.
exceedstrue for breaches only. Filtered before paging, so total is an exact breach count and limit=1 returns a breach.
formatjson (default), csv, geojson. Exports allow limit up to 10000.
clusterExact batch/folder name.
superclusterEvery result whose cluster is filed under this campaign — one call for a whole town's survey instead of one per day. Case-insensitive.
analyteEvery 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.
methodOne exact method, e.g. lead-low-asv-v1, when analyte is too broad.
limitPer page. Default 100, max 1000.
offsetSkip this many, for paging.
includeDemoInclude 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"

Syncing into another system

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.

Response

{
  "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

Errors are { "error": "code", "message": "..." }. Branch on error; the message is for humans and may change.

StatusCode
400invalid_queryA parameter was malformed. The message says which.
401missing_keyNo Authorization header.
401invalid_keyKey not recognised.
401revoked_keyKey was withdrawn. Message says when.
404not_foundNo result with that sessionId.
429Rate limited. Back off and retry.

Limits and scope

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.