Tech.Earth for Developers

A versioned REST API and an MCP server for licensed partners: query UK environmental challenge intelligence, run evidence-cited research on the places and problems you care about, and keep that research going on a cadence you set.

How it works

Ask Tech.Earth to research the places and problems you care about — once, or on a schedule you set — get told when a run finishes, review and decide the Challenge candidates it found, and see them alongside public Challenges and clusters. Every step is an API call, or an MCP tool if an AI agent is doing the work.

Start a runPOST /research-runs202 + run statusQueuedquotas checkedruns in the backgroundResearchreads sources,drafts candidatesRun finishesevent recorded,preview preparedYou are toldsigned webhook, orGET /eventsReviewcandidates, overlaps,preview groupsDecideapprove or reject;same readiness checksYour Challengeprivate to you,clustered with yours

The top row happens on our side once you ask; the bottom row is you acting on what came back. You never need to keep a request open: start a run (or create a schedule), then listen for the webhook or read /events.

Your records are private to your tenant

Shared evidenceSources, claims andobservationsWho sees itEvery research run readsit; candidates cite itPublic recordsPublished Challengesand clusters fromTech.Earth researchWho sees itEveryone: the publicsite and every partnerYour tenant’sResearch runs, candidates,overlaps, Challenges,clusters, webhooks, eventsWho sees itOnly you, andTech.Earth reviewers.Never public.Comparisons, links and clusters never mix your records with another partner’s.

Everything your work creates belongs to your tenant: no other partner sees it, and it never appears on the public site, even once a Tech.Earth reviewer has published it. You see public records plus your own. Asking for another partner's record returns not-found, never a permission error, so you can't even tell it exists.

What to expect

  • Runs are queued. Starting one answers 202 straight away and it runs in the background. Each tenant has a limit on runs in progress and runs per month; over either, you get 429.
  • You describe what to research, not how. The kind of run, its research budget and the sources it searches are set by Tech.Earth.
  • Approve accepts the six fields as shown. The values on dimensions[] are accepted with approve — you do not confirm them in a separate step. Resolve overlaps[] where blocksApproval is true first. Remaining readiness issues (unusable evidence, an open contradiction, a blocking overlap, missing required text, or a dimension that was never computed) still answer 409 with the blockers. Approval creates a Challenge your tenant owns and publishes it to your tenant. It does not put that Challenge on the public site.
  • A reject tells the next run why. Rejecting a candidate needs a rationale and a rejectionKind: do_not_repeat so that place, population, or framing is not proposed again, or improve so the next run replaces the candidate with what the rationale names. The next run of the same recurring research reads that choice. An improve note does not override missing evidence. Flagging needs_correction is only a correction on this candidate; it does not steer later runs.
  • Notifications are never lost. Webhooks are signed and retried for about a day, and every event can be read again from /events.
  • Research can keep going. A research schedule queues a run on the cadence you set (default daily, never more often than every six hours). Each run is the same as one you start yourself; later runs are steered away from candidates you already have.

Recurring research

Create a schedule once with POST /research-schedules (or the research_schedule.create MCP tool). Give it a name and the same research body you would send to start a run, plus how often to repeat it. We queue a run when it is due. You keep listening for run events the way you already do; you do not poll a clock.

  • Cadence is yours. cadenceMinutes is at least 360 (6 hours) and at most 129600 (90 days); omitted, it is 1440 (24 hours). Set runTime (HH:MM) and timezone (IANA such as Europe/London or an offset such as UTC+1) to pin each tick to a local clock — 1440 minutes at 09:00 in UTC+1 is every day at 9am UTC+1. startAt sets the first run; omit it or pass a time in the past and, without a runTime, the first run is due immediately.
  • Optional rotation. focuses[] (at most 20) is a list of partial run bodies. Each tick uses the next entry, in order, and whatever a focus names replaces the schedule's own value for that run. Naming places replaces both place lists.
  • Each tick is a real run. It counts towards your in-progress and monthly run quotas. Over either, that tick is skipped (research_schedule.skipped, reason quota_exceeded), not failed. Runs of the same schedule never overlap: if the last one is still going, the tick is skipped (previous_run_in_progress) and the next attempt waits a full cadence.
  • Later runs look for what is new. We brief each run with titles and places earlier runs of this schedule already found, and skip hotspots already covered. A finding that matches an unreviewed candidate you already have (pending review or needs correction) is merged into that card — challenge_candidate.updated— instead of opening a duplicate. A finding that matches a Challenge you already approved is not opened as a second card. challenge_candidate.created is a candidate new to you. Both are published with the run's completed or partial event.
  • Refresh one Challenge you already approved. Name it with challengeId. That is the whole request: do not also send a research question, places, domains or targets. The question and places come from the Challenge. A manual refresh is POST /research-runs with that id: one run, started when you send it, with no clock. A recurring refresh is the same id on a schedule; cadenceMinutes and optional runTime are how often, and every tick refreshes that Challenge. A newer finding is its own candidate. updatesApprovedChallenge is true and existingChallenge points at that Challenge. The event is challenge_candidate.update_proposed, and its data includes existingChallengeId. Load just those cards with GET /challenge-candidates?updatesApprovedChallenge=true. Approving one attaches the new evidence. It does not rewrite the Challenge. If nothing newer is found, the run finishes with no new card: research_run.completed with challengeUpdateCount 0. A finding that is not about the named Challenge is not a card. A run or schedule without challengeId only looks for new Challenges, unless the schedule opts into the update clock below. A link_to_existing candidate with updatesApprovedChallenge false is not this update: it is a duplicate or overlap someone recommended linking.
  • Optional updates on Challenges this research already published. Set refreshOwnedChallenges to true and give that check its own clock: updateCadenceMinutes (same limits as cadence), plus optional updateRunTime and updateTimezone. Example: update every 30 days at 9am UTC is updateCadenceMinutes 43200, updateRunTime 09:00, updateTimezone UTC. That tick is its own run. It does not re-ask the research question. It names up to eight Challenges this schedule already got approved, oldest evidence first, and asks only whether newer evidence exists. A newer finding is challenge_candidate.update_proposed. If nothing is newer, or this schedule has not published a Challenge yet, there is no new card (no published Challenge yet is research_schedule.skipped with reason no_owned_challenges, and the clock still moves on). Omitted, the flag is false: later discovery ticks still only look for new Challenges. Do not combine it with challengeId.
  • A schedule looks after itself. Three consecutive failed runs pause it (research_schedule.paused). Seven consecutive runs that find no new candidate emit research_schedule.exhausted so you can broaden the question or pause it. PATCH status to paused or active yourself; resuming resets the failure count and makes the next run due now. DELETE stops further runs; runs already made and their candidates stay.
  • How many you can keep active. Default is five active schedules per tenant (an admin can raise it). Creating one more answers 429. Pause or delete one first. Places are resolved when you create or change the schedule, so a place that does not resolve is refused then, not on the first tick.

Getting access

Access is by API key. Sign in to request access from My Account. An admin will review the request, set scopes and rate limits, and then you can generate your key in the browser — the plaintext is shown once and never stored. Admins can also issue a key directly and share it through a secure channel.

Every request - REST or MCP - must include Authorization: Bearer <your key>. Keys are scoped (below) and rate-limited per key, per tier.

Scopes & rate limits

Every key is issued with one or more scopes. Request the scopes your integration needs — a key with only challenges:read cannot call cluster endpoints, for example. An admin decides the final grant. My Account can request the two public read scopes; every other scope below is admin-grant only.

Scopes govern what you can call. Which records you see is a separate, non-negotiable boundary: endpoints that return your own submissions, ownership claims, research runs, candidates, webhooks and events are always filtered to your tenant, whatever scopes your key holds, and Challenges and clusters your tenant owns are visible to it alongside public ones. Another tenant's record returns not-found, never a permission error.

ScopeGates
challenges:read/v1/challenges, /v1/challenges/[id], and the challenge.* MCP tools. Requestable from My Account.
clusters:read/v1/clusters, /v1/clusters/[id] (your suggested or approved clusters) and the cluster.get MCP tool. Requestable from My Account.
research:initiate/v1/places, /v1/research-runs (GET and POST), /v1/research-runs/[id], /v1/challenge-enrichments, /v1/challenge-enrichments/[id], /v1/research-schedules (GET and POST), /v1/research-schedules/[id] (GET, PATCH, DELETE), /v1/webhooks, /v1/events, and the places.search, research.*, research_schedule.*, events.list MCP tools. Admin-grant only.
submissions:create/v1/submissions (GET and POST), /v1/submissions/[id], the submission.create MCP tool. Admin-grant only.
ownership:request/v1/ownership-claims (GET and POST), the ownership.request MCP tool. Admin-grant only.
challenge-candidates:read/v1/challenge-candidates, /v1/challenge-candidates/[id], /v1/challenge-candidates/preview-clusters, /v1/challenge-candidates/cross-tenant-preview-clusters, /v1/candidate-overlaps and the candidate.* read MCP tools, including candidate.preview_cross_tenant. Returns unreviewed candidates: your own tenant's, from the research runs it started. The cross-tenant preview, when Tech.Earth has enabled it for your integration, also groups similar candidates across that integration's child tenants. A tenant administrator can issue it.
challenge-candidates:decidePOST /v1/challenge-candidates/[id]/decision, POST /v1/challenge-candidates/preview-clusters/[groupKey]/decision, POST /v1/challenge-candidates/cross-tenant-preview-clusters/[groupKey]/decision and POST /v1/candidate-overlaps/[id]/decision: approve, reject or flag your tenant's candidates, decide a preview group, and resolve their overlaps; POST /v1/clusters/refresh; POST /v1/clusters/[id]/decision; the candidate.decide, candidate_preview.decide, candidate_preview_cross_tenant.decide, candidate_overlap.decide and cluster.decide MCP tools. The cross-tenant decision is an integration key with no X-Tenant-Id, and it decides each member in the tenant that owns it. A tenant administrator can issue it.

Rate limit tiers: standard (60 requests/min - the default), elevated (300/min), and internal_partner (1000/min). Limits are per key, shared across REST and MCP calls made with the same key. A request over the limit returns 429.

REST API - /v1

Base URL: https://tech.earth/api/v1. Everything returns JSON. This is a versioned, stable contract - once you integrate against it, the response shape is frozen (additive changes only).

Most endpoints are read-only GETs. Research runs, research schedules, submissions and ownership claims accept a POST that creates a reviewable draft or request. With challenge-candidates:decide you can also decide your own tenant's candidates: an approval accepts the six dimension values as shown and creates a Challenge your tenant owns, then publishes it to your tenant. Resolve blocking overlaps first; remaining readiness issues still refuse the approve. That does not put the Challenge on the public site, and nothing you can call here grants ownership; a Tech.Earth reviewer does that.

Send an Idempotency-Key header with any POST. It is optional but strongly recommended: retrying with the same key returns the original result instead of creating a second record. Reusing a key with a different body is rejected with 409, as is retrying while the first request is still in flight.

The full contract, request and response schemas included, is an OpenAPI 3.1 document at /api/v1/openapi.json.

EndpointParamsReturns
/challenges
  • type?
  • updatedSince? (ISO 8601)
  • search?
  • limit? (default 50, max 200)
  • offset? (default 0)
Every public published Challenge (plus your own tenant's published ones), ranked by impact score - standalone or a cluster member, but never a cluster itself - plus a pagination object ({ total, limit, offset, hasMore }). Browse clusters at /v1/clusters instead, which lists each one's published member Challenges under challenges[]. See "Challenges vs. clusters" below.
/challenges/{id}-A single published Challenge or Cluster by id. A Challenge comes back as the full record - see "The Challenge record" below.
/clusters
  • challengeType?
  • region?
  • limit? (default 50, max 200)
  • offset? (default 0)
Public, aggregate-only demand clusters (plus your own tenant's approved ones), each with a demandState of committed / expressed / potential (potential means the shared demand is inferred from comparable evidence, not confirmed by the organisations), a challenges[] list of its published member Challenges, and the domains[] those members declare, plus a pagination object ({ total, limit, offset, hasMore }). See "Challenges vs. clusters" below for what challenges[] and domains[] do and do not include.
/places
  • q? (name or ONS/GSS code)
  • placeType? (country | country_group | nation | region | combined_authority | local_authority)
  • parentId?
  • regionCode?
  • limit? (default 50, max 200)
  • offset? (default 0)
UK places a research run can be scoped to — the United Kingdom and its country groups, the four nations, the nine English regions, combined authorities, and local authorities — plus a pagination object. Each row has id, name, placeType, parentId, regionCode and code (ONS/GSS). Take id and send it as placeIds on POST /research-runs. Wards, sites and water geographies are not listed. Requires research:initiate.
POST /research-runs
  • researchFocus?
  • organisation? { name, website, organisationId? } (one organisation; not combined with challengeId or seed)
  • placeIds? (from GET /places)
  • placeCodes? (ONS/GSS; prefer placeIds)
  • domains?
  • period? { start, end }
  • externalRef?
  • webhookId?
  • targets[]? (advanced)
  • challengeId? (refresh one published Challenge you own; not combined with a question, places, domains, targets or organisation)
  • seed? { subjectRef, title, summary?, vertical?, theme?, geography?, leadOrganisation?, evidenceLinks?[{url, label?}], focusNote? } (one challenge you hold; not combined with researchFocus, challengeId, targets or organisation; not on a schedule)
Starts a research run for your tenant on what you describe. 202 with the run's status; it runs in the background, so poll it, read /events or register a webhook. The request echoes organisation when you sent one: organisationId, name and website. The run uses that organisation's site, and its ONS area when it has one, and each candidate lists it. The run type, discovery budget and sources are set by the platform, and sending them is refused. Quotas per tenant: runs in progress and runs per calendar month (429 when reached). externalRef is your own id for the run, unique per tenant. challengeId refreshes that published Challenge now: one run, no clock. A newer finding is a candidate with updatesApprovedChallenge true and existingChallenge set, announced as challenge_candidate.update_proposed. Approving it attaches the evidence. If nothing newer is found, the run finishes with no new card. A link_to_existing candidate with updatesApprovedChallenge false is a duplicate someone recommended linking, not this update. seed researches one challenge you hold that Tech.Earth does not: one candidate with recommendedAction seeded_finding and subjectRef set to your challenge id, announced as challenge_candidate.finding_proposed. Approving it records the decision and does not publish a Challenge. A second seed for the same subjectRef while that run is still open is 409. Requires research:initiate.
/research-runs
  • status?
  • triggerType?
  • externalRef?
Your tenant's own research runs, newest first. Filter triggerType=scheduled to see only runs a schedule started. Another tenant's runs are never listed.
/challenge-enrichments
  • researchRunId?
  • subjectRef?
Enrichments stored before a seeded run filed a candidate. A new seeded run does not write one: the finding is a candidate on GET /challenge-candidates (recommendedAction seeded_finding). Requires research:initiate.
/challenge-enrichments/{id}-One enrichment stored before seeded runs filed candidates. Not-found for another tenant's enrichment. Requires research:initiate.
/research-runs/{id}-One run: status, a coarse stage (queued, discovering_sources, gathering_evidence, drafting_candidates, finished), what was asked, and how many candidates it has produced. A scheduled run has triggerType scheduled and names the schedule that started it. Not-found for another tenant's run, rather than a permission error.
POST /research-schedules
  • name
  • cadenceMinutes? (default 1440, min 360, max 129600)
  • runTime? (HH:MM local time)
  • timezone? (IANA or UTC+1; used with runTime)
  • refreshOwnedChallenges? (default false)
  • updateCadenceMinutes? (required when refreshOwnedChallenges is true; e.g. 43200 = 30 days)
  • updateRunTime? (HH:MM)
  • updateTimezone? (IANA or UTC+1)
  • challengeId? (refresh that Challenge on every tick; not combined with a question, places, focuses, organisation or refreshOwnedChallenges)
  • startAt?
  • focuses[]? (partial run bodies, rotated one per run)
  • researchFocus?
  • organisation? { name, website, organisationId? } (every tick; not a focus field)
  • placeIds? (from GET /places)
  • placeCodes? (ONS/GSS; prefer placeIds)
  • domains?
  • period? { start, end }
  • webhookId?
  • targets[]? (advanced)
Creates a recurring research schedule for your tenant. 201 with the schedule. The request echoes organisation when you sent one. Every run body it can produce is resolved now (a place or organisation that does not resolve is refused). Each due tick queues a run the same way POST /research-runs does — same quotas, same events — without an externalRef (that is per run). At most five active schedules by default (429 when reached). Requires research:initiate.
/research-schedules-Your tenant's own research schedules, newest first. Archived ones are never listed.
/research-schedules/{id}-One schedule: name, status (active or paused), cadence, timezone, runTime, nextRunAt, the run body (including challengeId when every tick refreshes that Challenge), focuses and nextFocusIndex, lastRunId, lastSuccessAt, failing, consecutiveFailures and consecutiveEmptyRuns. Not-found for another tenant's schedule, or one you have deleted.
PATCH /research-schedules/{id}
  • name?
  • cadenceMinutes?
  • timezone?
  • runTime?
  • refreshOwnedChallenges?
  • updateCadenceMinutes?
  • updateTimezone?
  • updateRunTime?
  • focuses[]?
  • status? (active | paused)
  • any run-body field (replaces the whole body, including challengeId)
Changes what it names, from the next run. New focuses restart the rotation. status pauses or resumes; resuming resets the failure count and makes the next run due now. Requires research:initiate.
DELETE /research-schedules/{id}-204; no further runs. Runs already made, and their candidates, are kept; a run in progress finishes. Requires research:initiate.
POST /webhooks
  • url (https, port 443, public host)
  • eventTypes? (default: all — research_run.*, research_schedule.*, challenge_candidate.*)
  • description?
Registers a webhook; 201 with its signingSecret, shown only this once. Every delivery carries Techearth-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, plus Techearth-Event-Id (the same on every retry - de-duplicate on it). Verify before trusting the body and reject a t more than five minutes from your clock. Answer 2xx; anything else is retried with backoff for about a day. At most five active webhooks. Requires research:initiate.
/webhooks-Your tenant's active webhooks, never their secrets
DELETE /webhooks/{id}-204; no further deliveries to it, including queued retries
/events
  • since? (cursor)
  • limit? (default 100, max 500)
Your tenant's event log, oldest first — the same bodies webhooks carry. Run events: research_run.completed, .partial, .failed and .cancelled, with the run's id, your externalRef, researchScheduleId when a schedule started it, newCandidateCount, updatedCandidateCount, challengeUpdateCount, seededFindingCount and links to its candidates. A seeded run's completion also carries subjectRef. Candidate events published with a completed or partial run: challenge_candidate.created (new to you), challenge_candidate.updated (an unreviewed candidate gained this run's evidence), challenge_candidate.update_proposed (a named Challenge refresh found newer evidence; data.existingChallengeId, and the candidate's updatesApprovedChallenge is true) and challenge_candidate.finding_proposed (a seeded run filed a finding for a challenge you hold; data.subjectRef, data.externalRef). A link_to_existing candidate with updatesApprovedChallenge false is not that event. A seeded finding is not challenge_candidate.created, and approving it does not publish a Challenge. If a seeded run finds nothing useful, the run event has seededFindingCount 0 and no finding event. Schedule events: research_schedule.skipped (previous_run_in_progress or quota_exceeded), .paused (three consecutive failed runs) and .exhausted (seven consecutive runs with no new candidate). A webhook that lists eventTypes must include challenge_candidate.update_proposed or challenge_candidate.finding_proposed to receive that type; omit eventTypes to receive every type. Recover a missed delivery here: pass the cursor of the last event you handled as since, and keep nextCursor. A completed run's event is published once its candidates' preview is ready.
POST /submissions
  • organisationId
  • submitterUserId
  • title
  • societalImpactStatement
  • submitterDeclaration
  • placeScope?
  • populationScope?
  • suggestedWording?
  • evidenceDescription?
Creates a draft Challenge submission. 201 with the submission. submitterUserId must be an institutional user of your tenant AND a verified member of organisationId - otherwise 403. Requires submissions:create.
/submissions-Your tenant's submissions
/submissions/{id}-A single submission by id
POST /ownership-claims
  • organisationId
  • challengeId
  • claimantUserId
  • membershipCheck?
  • scope?
  • startDate?
  • endDate?
Raises a pending ownership claim for review. 201 with the claim - never an approved claim. Requires ownership:request.
/ownership-claims-Ownership claims raised by your tenant, with their review state
/challenge-candidates
  • reviewState? (comma-separated: pending_review, needs_correction, approved)
  • recommendedAction? (new_candidate | link_to_existing | seeded_finding)
  • updatesApprovedChallenge? (true | false)
  • subjectRef? (your id for a seeded challenge)
  • researchRunId?
  • updatedSince? (ISO 8601)
  • limit? (default 50, max 200)
  • offset? (default 0)
Challenge candidates that have not been rejected in-app, ordered by updatedAt then id, plus a pagination object. Rejected candidates never appear here (or on /challenge-candidates/{id}). updatesApprovedChallenge is true only when a refresh of a Challenge you named proposed this candidate as newer evidence (existingChallenge names it); pass updatesApprovedChallenge=true to load just those. A recommendedAction of link_to_existing with updatesApprovedChallenge false is a duplicate or overlap someone recommended linking, not that update. recommendedAction seeded_finding is a finding for a challenge you seeded: subjectRef is your id for it, and approving it does not publish a Challenge. Filter with subjectRef to load findings for one challenge. Each candidate names what it references rather than returning bare ids: affectedPlaces (with region names, from this candidate's own affectedPlaceIds), organisations (from its proposedOrganisationRelationships), the existing or resulting Challenge, and the run's declared domains. supportingOrganisations is the subset of proposedOrganisationRelationships a reviewer has already assigned a supporting role to ("owner", "responsible_authority", "funder" or "potential_delivery_contributor") - organisations evidenced as working on the candidate, councils included, distinct from an organisation only affected by it; empty until a reviewer has made that assignment, never inferred from the model's own unreviewed role guess. suggestedSupportingOrganisations is the pipeline's own side of that same distinction: relationships no reviewer has acted on yet (assigned or declined) whose model-proposed role - itself evidence-matched, not a bare guess - is one of those supporting roles. A relationship moves out of suggestedSupportingOrganisations the moment a reviewer decides on it, landing in supportingOrganisations if assigned or neither list if declined. This is the lightweight, no-evidence view - places and organisations here carry no evidenceSupport/evidenceBasis classification, and inferredRollupPlaces/namedUnlinkedOrganisations are omitted entirely. It also leaves out the narrative text (proposedProblemStatement, societalImpactRationale, evidenceSummary, uncertaintyAndLimitations, contradictoryEvidence, evidenceGaps, affectedPopulationsOrAssets, eligibilityRationale), the raw evidence-id arrays (affectedPlaceIds, supportingClaimIds, supportingObservationIds, dimensionEvidence), and internal scoring/provenance fields (proposedSolutionCategories, scopeChange, proposedConfidenceScore, proposedConfidenceReasons, confidenceMethodVersion, finalConfidenceReason, confidenceReviewedAt, extractionMethod, extractionMethodVersion) that only matter once a reviewer has opened one specific candidate. Open GET /challenge-candidates/{id} for all of that, plus the evidence-scope card (what evidence supports each place/organisation) and the cited claims, observations and sources themselves. updatedAt changes whenever the candidate or one of its dimension assessments, overlaps or impact estimates changes, so to sync, pass the newest updatedAt you have seen as updatedSince (an ISO 8601 date, or a timestamp with an offset). Requires challenge-candidates:read.
/challenge-candidates/preview-clusters
  • researchRunId?
Pending candidates grouped by the same rules the clustering pass applies to approved Challenges — a preview, never a cluster, and not carried over on approval (an approved Challenge is re-compared from its own approved descriptor). Each group has a groupKey for POST /challenge-candidates/preview-clusters/{groupKey}/decision. groups: candidates with the same condition, population or asset and a compatible period at distinct measured places, each with an overview, every member (measured places, period, where its condition was read, measure, evidence counts) and every pair's per-axis comparison (condition, population/asset, geography, period, measure) with the reason on each axis. duplicates: pairs that are the same Challenge. overlapReview: broader/narrower conditions or nested/overlapping places, for a reviewer to judge. notCompared: candidates with no stored semantic descriptor or a condition the shared vocabularies do not recognise. Uses stored descriptors only. One tenant only: another tenant's candidates are never in this grouping. Requires challenge-candidates:read.
/challenge-candidates/{id}-One candidate with what a reviewer decides on: approval readiness with itemised risks (null once decided), the six dimension assessments (each with citedMarkdown and citationNs from that dimension's own evidence), a numbered citations catalog, and citedMarkdown on narrative fields with each source cited after the sentence that uses it (a run of sentences using the same sources is cited once, at its end) — the same ([NEMSA][1]) shape as GET /v1/challenges/{id}. Approve accepts the six values on dimensions[]; readiness does not list those as unreviewedDimensions. Resolve overlaps[] where blocksApproval is true before you approve. A no-signal finding (no time-critical or shared signal detected) reports an absence and carries no citations. Also every place it references (named), cited claims and observations paired by observationIds/claimIds with their sources, overlaps with other candidates, current impact estimates, and the existing or resulting Challenge with its lifecycle state. supportingOrganisations and suggestedSupportingOrganisations carry the same meaning as on GET /challenge-candidates: the subset of proposedOrganisationRelationships already reviewer-assigned a supporting role ("owner", "responsible_authority", "funder" or "potential_delivery_contributor"), and the pipeline's own not-yet-reviewed suggestion of the same, respectively. A rejected candidate is not-found. Requires challenge-candidates:read.
POST /challenge-candidates/{id}/decision
  • decision (approve | reject | needs_correction)
  • version (the candidate's updatedAt)
  • note? or rationale? (one required to reject or flag; the same decision rationale as the in-app review)
  • rejectionKind? (do_not_repeat | improve, required to reject)
Decides one of your candidates. A version older than the candidate's current one is refused (409 version_conflict, with currentVersion) so you never decide something you have not seen. Approve accepts the six dimension values as shown on GET /challenge-candidates/{id}. Approval of a seeded_finding records the decision and does not publish a Challenge (challengeId is null). Approval of any other candidate creates (or links to) a Challenge your tenant owns and publishes it to your tenant; lifecycleState published means it is on GET /challenges for your tenant, not on the public site. Remaining readiness issues (unusable evidence, an open contradiction, a blocking overlap, missing required text, or a dimension that was never computed) answer 409 not_ready with those blockers — not the six shown fields. There is no override. A reject requires rejectionKind (do_not_repeat or improve) and a rationale, which is shown to the next run of the same recurring research and filed internally for prompt follow-up. Requires challenge-candidates:decide.
POST /challenge-candidates/preview-clusters/{groupKey}/decision
  • decision (approve | reject)
  • members[] (candidateId + version for every member of the group)
  • note? or rationale? (required to reject)
  • rejectionKind? (do_not_repeat | improve, required to reject)
  • researchRunId? (same as GET preview-clusters, if you used one)
Approves or rejects every pending candidate in one preview group. This is not a cluster and does not persist one. members must be the exact set GET preview-clusters returned for that groupKey; a changed grouping answers 409 group_changed. Approval accepts the six shown dimension values on each member and preflights the remaining readiness gates (409 not_ready lists blockers per candidate), then publishes each resulting Challenge to your tenant. It does not put those Challenges on the public site. A reject requires rejectionKind and files one note for the group. Requires challenge-candidates:decide. This route does not decide a cross-tenant group.
/challenge-candidates/cross-tenant-preview-clusters
  • X-Tenant-Id? (omit for the integration read)
  • tenantId? (integration read only)
  • candidateIds? (repeat, or comma-separated)
Pending candidates from every approved child of your integration, grouped by the same rules as GET preview-clusters. A preview, never a cluster. 404 until Tech.Earth enables it for the integration. Omit X-Tenant-Id for the integration read: each member has tenantId, version (its updatedAt), organisations[{name}], and the same member fields as a per-tenant preview. No lead organisation is chosen. Send X-Tenant-Id, or call with a child tenant key, for clusters that contain that tenant; a member from another tenant is then only title and organisations[{name}] — no id, places, measure, or evidence. Pair axes, duplicates, overlap review, and the narrative summary are omitted on that read. tenantId and candidateIds return clusters that contain that child or any of those ids, still with every member, and they do not change groupKey. An unrelated child is 404; an unrelated id returns no cluster. Requires challenge-candidates:read.
POST /challenge-candidates/cross-tenant-preview-clusters/{groupKey}/decision
  • decision (approve | reject)
  • members[] (candidateId + version for every member of the group)
  • note? or rationale? (required to reject)
  • rejectionKind? (do_not_repeat | improve, required to reject)
Approves or rejects every pending candidate in one cross-tenant preview group. Integration key only: do not send X-Tenant-Id (400). Each member is decided in the tenant that owns it, so approval publishes that Challenge to its owning tenant and does not put it on the public site. members must be the exact set the integration read returned; a changed grouping answers 409 group_changed. A reject requires rejectionKind and files one note for the group. The next read omits decided candidates, so this groupKey is then not found. Requires challenge-candidates:decide.
/candidate-overlaps
  • candidateId? | researchRunId? (exactly one)
Overlaps between your candidates, each with a recommended action and whether it blocks approval. Requires challenge-candidates:read.
POST /candidate-overlaps/{id}/decision
  • action (consolidate | local_instance | parent_child | link_as_related | keep_separate)
  • note
  • survivorCandidateId?
  • sourceCandidateId?
  • targetCandidateId?
  • acknowledgeDirectionConflict?
Resolves an overlap between two of your candidates. consolidate merges the other into survivorCandidateId; local_instance and parent_child relate sourceCandidateId to targetCandidateId, and a direction the evidence reads the other way is refused unless acknowledged. Requires challenge-candidates:decide.
GET /clusters/{id}-One suggested or approved cluster your tenant owns. updatedAt is the version POST /clusters/{id}/decision needs. Rejected, merged, split and another tenant's clusters are not-found. Published public clusters stay on GET /clusters. Requires clusters:read.
POST /clusters/{id}/decision
  • decision (approve | reject)
  • version (the cluster's updatedAt)
  • deanonymisationChecked (true, required to approve)
  • note? or rationale? (required to reject)
Approves or rejects one of your clusters, through the same checks as in-app review. A stale version answers 409 version_conflict. Approval requires deanonymisationChecked and refuses auto-generated placeholder title or summary (409 placeholder_copy). A reject note is filed internally for prompt follow-up. Requires challenge-candidates:decide.
POST /clusters/refresh-Clusters your tenant's own approved Challenges now instead of waiting for the scheduled pass. Proposes suggested clusters; decide them with POST /clusters/{id}/decision. At most once every ten minutes (429 with Retry-After). Requires challenge-candidates:decide.

Paging and incremental sync. Use updatedSince to pull only what has changed rather than re-reading the whole register; on a Challenge it compares against updatedAt, on a Cluster against the date it was last reviewed. Results are ordered by impact score with a stable id tiebreak, so offset paging will not duplicate or skip rows.

Every /v1 response carries an x-correlation-id header. Quote it when reporting a problem - it is the same id our request log records for your call.

Example request

$ curl https://tech.earth/api/v1/challenges?limit=2 \
    -H "Authorization: Bearer <your key>"

{
  "challenges": [
    { "kind": "challenge", "id": "3f9a1c2e-...", "title": "Elevated PM2.5 across East London", "impactScore": 72 }
  ],
  "pagination": { "total": 1, "limit": 2, "offset": 0, "hasMore": false }
}

Errors

Every error response is a structured object, not a bare string:

{
  "error": {
    "category": "validation",
    "code": "validation_failed",
    "message": "limit must be an integer between 1 and 200"
  }
}

Branch on category - it is a closed set: authentication, scope, policy, validation, conflict, rate, review_state, not_found, connector, internal. code is a stable machine-readable reason within a category; message is human-readable and may be reworded, so don't match on it.

Authentication failures are deliberately undifferentiated. An invalid key, a revoked key, an expired key, a suspended key and a key whose tenant is not approved all return the same 401 with the same message - the response never tells you which, so don't try to distinguish them. Retrying will not clear any of them.

StatusWhen
400The request was malformed - an unknown filter value, an out-of-range limit, a bad timestamp, or a missing required field on a POST
401Bearer token missing or malformed, or the key is invalid, revoked, expired, suspended, or its tenant is not approved
403The key is valid but lacks the required scope, or the request breaks a policy rule (e.g. the submitter is not a verified member of the organisation)
404The requested resource doesn't exist, isn't published, or belongs to another tenant - these are deliberately indistinguishable
409An Idempotency-Key was reused with a different body, a request with that key is still in flight, or the record is in a review state that forbids this change
429The key's rate limit tier has been exceeded - retry after a short backoff
500Unexpected server error - safe to retry; contact your account manager if it persists

Challenges vs. clusters

These are two different record shapes, told apart by kind: a standalone "challenge" (a single declared Challenge - status, origin, and a full impact sub-score breakdown) or a "cluster" (several related Challenges grouped together - a shared pattern summary and an organisation-type breakdown instead of per-Challenge fields). Each has its own resource: GET /v1/challenges lists Challenges only - every entry's kind is "challenge", a cluster member or not, and a cluster itself never appears there - while GET /v1/clusters lists clusters only. GET /v1/challenges/{id} is the exception: it takes either kind of id and returns whichever record it names, so kind is how you tell which one came back. Every Challenge entry, on either endpoint, carries regions[], a list of { code, name } pairs, since a Challenge can span multiple regions. Region codes and names are on each entry's regions[] — there is no separate region catalogue.

GET /v1/challenges

{
  "challenges": [
    {
      "kind": "challenge",
      "id": "3f9a1c2e-...",
      "title": "Elevated PM2.5 across East London",
      "challengeType": "air_quality",
      "impactScore": 72,
      "impactConfidence": "High",
      "regions": [{ "code": "GB-LDN", "name": "London" }],
      "evidenceCount": 14,
      "status": "active",
      "origin": "org_report",
      "createdAt": "2026-01-15T09:12:00.000Z",
      "updatedAt": "2026-03-10T16:04:00.000Z"
    }
  ],
  "pagination": { "total": 214, "limit": 50, "offset": 0, "hasMore": true }
}

Getting from a cluster to its Challenges

The link runs both ways. A Challenge record carries clusters[], and every cluster from /v1/clusters carries challenges[] - its published member Challenges, each with id, title, challengeType, impactScore and updatedAt, ranked by impact score the same way /v1/challenges ranks them. Follow any id to /v1/challenges/{id} for the full record.

challenges[] lists only members that are published in their own right, so it can be shorter than distinctChallengeCount - and empty on a cluster that plainly has members. That is the anonymity model working as intended: a cluster can be publishable in aggregate while the Challenges inside it are still in review. Read a short or empty challenges[] as "not public yet", never as missing data, and read organisationCount, organisationTypeBreakdown and distinctChallengeCount for the true size of the cluster. Nothing in challenges[] names an organisation.

A cluster also carries domains[] and domainSource. Nothing declares a domain at cluster level, so these are the domains its published members declare, deduplicated and ordered by how many of them declare each - most first, ties alphabetical - with each member's own domains on its entry in challenges[] so you can check the union against what it came from. domainSource is "member_challenges" when at least one published member declared a domain, and "undeclared" with an empty domains[] when none did or no member is published yet. We never classify a cluster's own title or shared pattern onto the six domains - the same rule that governs a Challenge - so an empty list means nobody declared one, not that the cluster has none.

GET /v1/clusters

{
  "clusters": [
    {
      "id": "9b2e7a10-...",
      "title": "Recurring surface-water flooding, North West",
      "challengeType": "floods",
      "demandState": "expressed",
      "organisationCount": 5,
      "distinctChallengeCount": 4,
      "domains": ["climate resilience", "water"],
      "domainSource": "member_challenges",
      "challenges": [
        {
          "id": "3f9a1c2e-...",
          "title": "Surface-water flooding on the A5103 corridor",
          "challengeType": "floods",
          "impactScore": 64,
          "updatedAt": "2026-03-10T16:04:00.000Z",
          "domains": ["climate resilience"]
        }
      ]
    }
  ],
  "pagination": { "total": 37, "limit": 50, "offset": 0, "hasMore": false }
}

The Challenge record

GET /v1/challenges/{id} returns everything a list row carries, plus the blocks below. All of it is drawn from the exact version a reviewer approved for publication - never a draft, and never a newer revision still under review.

FieldWhat it is
versionSnapshotThe approved version's own recorded content. Its shape varies with how the Challenge was authored, so read it defensively rather than assuming fixed keys. Brief-shaped statement arrays (publicWording, evidenceSummary, dimensionsNarrative, and the other narrative fields) keep the stored `text` unchanged and add a derived `citedMarkdown` string of the form "… ([NEMSA][1])", plus n/label on each resolved citation. Markers are never written into the stored version.
citations[]Numbered catalog behind citedMarkdown and dimensions[].citationNs. One row per source, in reading order, so two facts from the same report share [1]. url/licence/referenceMarkdown are present only when the source also qualifies for sourceLinks[]; otherwise unavailableReason is "licence" and the marker still renders. Restricted holdings are omitted entirely. Concatenate every non-null referenceMarkdown to turn citedMarkdown into a complete CommonMark document.
publishedAtWhen this version was approved for publication - distinct from createdAt and updatedAt.
domains[] / domainSourceThe research domain(s) this Challenge sits in, from a closed set of six: climate resilience, energy & buildings, water, transport & accessibility, public health & wellbeing, nature & environmental quality. Air quality, flood risk and heat are topics under one of these, never domains of their own - read challengeType for the topic. domainSource is "research_run" when the domain was declared by the research that produced this version, or "undeclared" with an empty domains[] when the Challenge came from an organisation submission or was created manually. We never infer a domain from a Challenge's own wording, so an empty list means nobody declared one - not that it has none.
organisations[]Organisations with an approved, evidenced relationship to this Challenge, each with a role, dates and confidence. Filter by role to find organisations working on the Challenge ("owner", "responsible_authority", "funder" or "potential_delivery_contributor") as distinct from an organisation only affected by it.
owners[] / ownershipDisclaimerOrganisations with an approved ownership claim. Ownership means accountability for the Challenge record, not responsibility for causing the problem - the disclaimer text says so, and must be shown alongside owners.
clusters[]Clusters this Challenge belongs to, each already past its own approval and anonymity gates.
dimensions[] / gaps[]The six explainable dimensions (scale, severity, priority, persistence, geographic coverage, shared demand potential). Each dimensions[] entry has band (machine key), label (people-facing wording), rationale and confidence, plus citedMarkdown (rationale with the same ([label][n]) suffix) and citationNs pointing at citations[]; both are empty of citations on a no-signal finding, which reports an absence. Anything not yet assessed is reported in gaps[] rather than silently omitted. There is no single composite challenge score.
solutionCategories[]Approved categories of intervention this Challenge calls for.
sourceLinks[]Upstream source URLs whose licence permits redistribution. Sources we hold under a restricted or unresolved licence are omitted, so this is a subset of the evidence behind the Challenge, not all of it.
evidenceProfileWhat kind of evidence stands behind this Challenge, which origin does not tell you. Counts of source formats (document/dataset/feed/api), publisher tiers (national/regional/authority/methodology), reviewer-set authority levels (High/Medium/Low, or "unrated" where the source carries no registered authority), and claim types (fact/metric/assertion/quote). observationBasis is the one to read first: observed vs projected vs unknown. "unknown" means the source never stated which - it is never folded into "observed", so do not treat it as measured fact. Counts cover all usable evidence; publishers[] names only the sources we may redistribute, so it is deliberately shorter than sourceCount.
evidence / contradictions[]Evidence count and date range, plus any open contradictions between sources. Contradictions are surfaced, never resolved silently in our favour.
mode / modeReasons[]Whether this record is backed by live, hybrid or mock data, and why. Never present a mock or hybrid record as verified live data.

Not returned, by design: internal working notes, named individuals, and any AI-suggested relationship a reviewer has not confirmed.

MCP server (for AI agents)

Claude, Cursor, ChatGPT, or your own agent can call this same data as MCP tools over Streamable HTTP, using the same API key and scopes as the REST API - one key works across both surfaces, with one shared rate-limit budget.

{
  "mcpServers": {
    "tech-earth": {
      "url": "https://tech.earth/api/mcp",
      "headers": { "Authorization": "Bearer <your key>" }
    }
  }
}

Tools are filtered by your key's scopes: a tool you lack the scope for is not listed and cannot be called. The tools read the same data as the REST endpoints, through the same gates - an agent cannot reach anything a REST caller with the same key could not.

Read tools

ToolArgumentsWhat it returns
challenge.searchtype?, updatedSince?, search?, limit?, offset?Published challenges, standalone or a cluster member - never a cluster - plus a pagination object. The same read path as GET /v1/challenges
challenge.getchallengeIdOne published challenge or cluster, as the full record described above
challenge.comparechallengeIdA, challengeIdBBoth entries and a field-by-field diff. Compares ranked fields only, not narrative blocks.
cluster.getchallengeType?, region?, limit?, offset?Aggregate-only demand clusters plus pagination. "potential" demandState is inferred from comparable evidence, not confirmed by the organisations.
places.searchq?, placeType?, parentId?, regionCode?, limit?, offset?UK places a run can be scoped to, as GET /v1/places. Use id as placeIds on research.start.
research.statusresearchRunIdStatus of one of your tenant's research runs, as GET /v1/research-runs/{id}
research.liststatus?, externalRef?Your tenant's research runs
research_schedule.list-Your tenant's research schedules, as GET /v1/research-schedules
events.listsince?, limit?Your event log, as GET /v1/events: run, candidate and schedule events, including challenge_candidate.update_proposed and challenge_candidate.finding_proposed
candidate.listreviewState[]?, recommendedAction?, updatesApprovedChallenge?, subjectRef?, researchRunId?, updatedSince?, limit?, offset?Your candidates, as GET /v1/challenge-candidates. updatesApprovedChallenge true is only a named Challenge refresh's matches. subjectRef loads findings for one challenge you seeded.
candidate.getcandidateIdOne candidate with everything a decision rests on
candidate.previewresearchRunId?Your pending candidates grouped by the clustering rules
candidate.preview_cross_tenanttenantId?, filterTenantId?, candidateIds[]?Cross-tenant preview, as GET /v1/challenge-candidates/cross-tenant-preview-clusters. Omit tenantId for the integration read. Set tenantId for the redacted read of clusters that contain that child. filterTenantId is the integration-read tenant filter.
candidate_overlap.listcandidateId? | researchRunId?Overlaps between your candidates

Write tools

Each of these creates something a human then reviews, or decides your own tenant's candidates and clusters. None of them publishes a Challenge, approves a claim, or changes a public record - there is no tool on this server that can.

ToolArgumentsWhat it creates
candidate.decidecandidateId, decision, version, note? or rationale?, rejectionKind? (required to reject)A decision on one of your candidates, as POST /v1/challenge-candidates/{id}/decision. Approval of a seeded_finding records the decision and does not publish a Challenge. Approval of any other candidate creates a Challenge your tenant owns and publishes it to your tenant. It does not put that Challenge on the public site. A reject requires rejectionKind (do_not_repeat or improve); the rationale is shown to the next run of the same recurring research.
cluster.decideclusterId, decision, version, deanonymisationChecked?, note? or rationale?A decision on one of your clusters, as POST /v1/clusters/{id}/decision. Approval requires deanonymisationChecked. A reject note is filed internally for prompt follow-up.
candidate_preview.decidegroupKey, decision, members[{candidateId, version}], note? or rationale?, rejectionKind? (required to reject), researchRunId?A decision on every candidate in one preview group, as POST /v1/challenge-candidates/preview-clusters/{groupKey}/decision. Does not persist a cluster. Approval publishes each resulting Challenge to your tenant and does not put it on the public site. A reject requires rejectionKind and files one note for the group.
candidate_preview_cross_tenant.decidegroupKey, decision, members[{candidateId, version}], note? or rationale?, rejectionKind? (required to reject)A decision on one cross-tenant preview group, as POST /v1/challenge-candidates/cross-tenant-preview-clusters/{groupKey}/decision. Integration key only: omit tenantId. Each member is decided in its own tenant. Approval publishes each resulting Challenge to that tenant and does not put it on the public site. A reject requires rejectionKind and files one note for the group.
candidate_overlap.decideoverlapId, action, note, survivorCandidateId?, sourceCandidateId?, targetCandidateId?, acknowledgeDirectionConflict?An executed overlap resolution, as POST /v1/candidate-overlaps/{id}/decision
research.startreason, researchFocus?, organisation? { name, website, organisationId? }, placeIds? (from places.search), placeCodes?, domains?, period?, externalRef?, webhookId?, targets[]?, challengeId?, seed?A research run, started exactly as POST /v1/research-runs starts one (the same quotas and validation) and queued. reason is required and recorded for audit - the REST equivalent does not take one. organisation researches that one organisation. challengeId refreshes that Challenge now. seed files one finding candidate for a challenge you hold (recommendedAction seeded_finding, subjectRef).
research_schedule.createname, cadenceMinutes?, runTime?, timezone?, refreshOwnedChallenges?, updateCadenceMinutes?, updateRunTime?, updateTimezone?, startAt?, focuses[]?, researchFocus?, organisation? { name, website, organisationId? }, placeIds?, placeCodes?, domains?, period?, webhookId?, targets[]?, challengeId?A research schedule, created exactly as POST /v1/research-schedules creates one. organisation applies to every tick. challengeId refreshes that Challenge on every tick. refreshOwnedChallenges adds a separate update clock for Challenges this schedule already published.
research_schedule.updateresearchScheduleId, name?, cadenceMinutes?, runTime?, timezone?, refreshOwnedChallenges?, updateCadenceMinutes?, updateRunTime?, updateTimezone?, status?, focuses[]?, plus any research.start run-body fieldChanges one of your schedules from the next run, as PATCH /v1/research-schedules/{id}
research_schedule.deleteresearchScheduleIdStops further runs of the schedule. Existing runs and candidates are kept.
submission.createorganisationId, submitterUserId, title, societalImpactStatement, submitterDeclaration, placeScope?, populationScope?, suggestedWording?, evidenceDescription?A draft Challenge submission awaiting review
ownership.requestorganisationId, challengeId, claimantUserIdA pending ownership claim awaiting review

A failed tool call returns the error as text with an error flag, rather than the JSON envelope the REST API uses. Scope denials and rate limits surface the same way - check the message, not a status code.

Questions? Sign in to request access from My Account, or contact your account manager.