ThriveFSM Site Readiness

API reference

Base URL https://srs.thrivefsm.com. All timestamps are ISO 8601 with an offset. All bodies are JSON. How an answer is worked out, in plain words, is in the guide, for people signed in.

Authentication

A bearer token on every request. There is no anonymous access, not even to a health check that reveals anything: the service is on the public internet and billing is per invocation, so authentication is a cost control as much as a security measure.

Authorization: Bearer srs_live_...

Keys are stored as SHA-256 hashes. A key cannot be read back to you, only replaced. A revoked key stops working immediately.

The planning screen calls the same endpoints for a person signed in on the service's own pages, with their session instead of a key. A preview's checks are counted against its allowance there, and a used-up allowance answers 403 checks_used_up on the query and the dispatch check. A key is never metered this way, and where a request carries both, the key wins. A session that cannot be used is refused with 401 sign_in_needed, or 403 with one of not_invited, no_company, suspended, ended, closed, no_subscription or cross_site (a request that did not come from the service's own pages).

A signed-in person's role is checked on every request. A Viewer may explore and read: availability:explain, constraints:area and GET /v1/decisions. A Planner may also check jobs (availability:query, dispatch:check, watches) and record decisions and verdicts; the company's lead and its admins may do all of that. A role that does not allow the request is refused with 403 role_not_allowed, whose message says what the role can do. A key is the company's, not a person's, and has no role.

Errors

Every error has the same shape, with a code that does not change and a message written for a person:

{ "error": { "code": "invalid_request", "message": "...",
             "details": [{ "field": "sites[0].geometry", "message": "..." }] } }
StatusCodeMeans
400invalid_requestThe body did not validate. details names each field and what was wrong with it.
400invalid_json, too_many_sites, unacceptable_reasonA body that is not JSON, more sites than one request takes, or a reason in accept that can never be accepted.
400no_verdicts, too_many_verdicts, missing_resolution_id, bad_resolution_id, bad_observedA verdicts send that is empty, over 500, or has an entry without a usable id or a known observed.
401unauthorizedMissing, unknown or revoked key.
403account_suspendedThe account is suspended. Contact us.
404not_found, unknown_topicNo such route, or a Street Manager topic that is not one of the three.
413payload_too_largeBody over 1 MB.
429rate_limitedRate limited. Retry-After says how long to wait.
500internal_errorSomething failed on our side, including the database being unreachable. The incident id finds the log line; quote it to us.
503watches_unavailableOnly from /v1/watches, when watches are not configured. The health check answers 503 when it is unhealthy.

Limits on a query: 500 sites, a horizon of 90 days, 1,000 coordinate pairs in one geometry, a body of 1 MB and a ref of 200 characters.

POST /v1/availability:query

Workable windows for a set of sites across a horizon.

{
  "horizon": { "from": "2026-10-21T00:00:00Z", "to": "2026-10-28T00:00:00Z" },
  "sites": [
    {
      "ref": "WO-1001",
      "geometry": { "type": "Point", "coordinates": [-1.4701, 53.3811] },
      "buffer_m": 50,
      "job_type": "METER_EXCHANGE",
      "horizon": { "from": "2026-10-21T08:30:00Z", "to": "2026-10-21T10:30:00Z" }
    }
  ]
}
FieldNotes
refYours. Echoed back untouched; we hold no idea what it refers to.
geometryGeoJSON, WGS84, longitude first. Point, LineString or Polygon, or their Multi forms.
buffer_mOptional. Defaults to the profile's own. 1–1000.
job_typeOptional. Your own code, mapped to a profile. Unmapped codes fall back and say so. Your job codes are set on the Kinds of work page, under Settings on the planning screen, by your company's lead or an admin, or by us.
profileOptional, instead of job_type: the kind of work by name. The built-in kinds are excavation, carriageway-works, footway-works, overhead, inspection, customer-premises, emergency and markings. Neither given, your default answers.
horizon (per site) Optional, and the one most worth using. Without it every site is answered over the whole request horizon, so a job scheduled Tuesday morning reports Friday's closures too. Must fall inside the request horizon.
knowledge_at (top level) Optional. Answers as of a past instant: what we knew then, under today's rules. The rules change with releases, so for what we actually told you, ask support for the logged answer, which records the rules that produced it. Never served from cache.

Each site comes back with windows tiling the horizon, and the response states what it was able to check:

{
  "completeness": {
    "complete": false,
    "computed_at": "2026-10-20T22:04:11Z",
    "sources": [{ "source": "street_manager",
                  "from": "2026-07-01", "to": "2026-10-20",
                  "gaps": [{ "from": "2026-10-01", "to": "2026-10-18",
                             "days": 18, "working_days": 12 }] }]
  },
  "results": [{
    "ref": "WO-1001",
    "buffer_m": 50,
    "profile": "default",
    "profile_source": "mapped",
    "windows": [
      { "from": "...", "to": "...", "status": "clear",
        "penalty_minutes": 0, "confidence": 0.9 },
      { "from": "...", "to": "...", "status": "blocked",
        "penalty_minutes": null, "confidence": 0.9,
        "reasons": [{ "code": "ROAD_CLOSURE_PLANNED", "severity": "hard",
                      "source": "street_manager", "evidence_id": "sns:permit:..." }] }
    ],
    "attribution": [{ "source": "street_manager",
      "statement": "Contains public sector information licensed under the Open Government Licence v3.0.",
      "url": "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/" }]
  }]
}
FieldNotes
completeness.complete Check this before acting on an answer automatically. false means at least one source has a hole in what it delivered, so a clear window may be clear only because nothing was received for those dates. The windows are still the best answer we have; they are not a complete one.
completeness.sources[].asked_to The last date this source has been asked about, or null when it has no forward limit. National Highways publishes a fourteen-day window and no archive, so an answer about a date beyond its asked_to was computed without consulting it, not because data is missing, but because it has not been published yet. Re-run nearer the date. Street Manager has no limit: permits are registered arbitrarily far ahead.
completeness.sources[].gaps The dates each source delivered nothing, inclusive. For Street Manager, a gap that began before your horizon ends can affect your answer even if it does not overlap it: a permit published inside the gap, for works after it, is missing until something more is published about it. For National Highways, which is read in full on every poll, only a gap overlapping your horizon does. working_days is usually the number that matters, since nothing much is published at weekends.
completeness.computed_at When coverage was last computed. Cached briefly, so this can trail the request by up to a minute; it is reported rather than implied.
not_held Present only when it matters, and it matters a great deal. The sources we do not hold for this site's location: its buffer reaches outside every area that source is loaded for, so a clear window says nothing about that source's works there. Street Manager's history is held for Sheffield today; the not_held on an answer is what says so for a site, not this page. Absent means every area-scoped source is held for the site. The dispatch check carries the same field on a job and raises coverage_gap; an area answer carries it for the whole area.
advisories Environment Agency flood warnings in force whose area reaches the site's buffer, most severe first: kind (severe_flood_warning, flood_warning or flood_alert), level (1 to 3), label, area_id, area_name, raised_at and changed_at exactly as published (the source gives no time zone), message, source and evidence_id. Beside the answer, never in it: no window, reason or verdict depends on one. An empty list means none in force; the field is absent only when the lookup failed, which advisory_feed.available says.
advisory_feed Which advisory feed the answer used, whether it could be read (available), and checked_at, when the feed was last read in full. It is polled every fifteen minutes; a checked_at much older than that means the warnings shown may be stale.
restrictions Standing traffic rules from traffic orders (D-TRO) within the site's buffer and in force at some point in the horizon, nearest first: one-way streets, banned turns, no entry, bus lanes, pedestrian zones, and parking, waiting and loading rules. Each has restriction_key, source (dtro), order_id, kind in D-TRO's own words (mandatoryDirectionOneWay, kerbsideNoWaiting), label in a planner's, category (permanent, experimental or temporary), detail, schedule in plain words when it holds only at some times, valid_from and valid_to (null when the order gives no start, or has no end), and distance_m; the explanation adds geometry. Beside the answer, never in it: they bear on a crew's route and parking, not on whether the work can go. A temporary closure ordered through D-TRO is not here: it is a constraint like any other, with source dtro. An empty list means none found; the field is absent only when the lookup failed, which restriction_feed.available says.
restriction_feed Which traffic order feed the answer used, whether this answer could read it (available), and read_at, when D-TRO's export was last read in full. It is read once a night, so an order made or withdrawn since read_at is not reflected; much more than a day old means a night's read failed. null means no full read is recorded, or it could not be looked up: unknown, not recent.
statusclear, degraded or blocked.
penalty_minutesAdded job duration. Zero when clear, null when blocked: a blocked window has no finite cost, and a very large number invites an optimiser to treat it as merely expensive.
confidenceHow well we understood the source records behind this window. Not a probability that the road is blocked, and it should not be used as one.
evidence_idThe payload this came from. Ask support and it can be produced.
profile_sourceexplicit when you named a profile, or named neither and your default answered; mapped from your job_type; or fallback when what you asked for matched nothing, and the job was answered with no rules.
cached, expires_atWhether this answer came from the cache, and when it leaves it. An answer is kept only while the store is unchanged, and ten minutes at most: anything written or withdrawn reaches the next ask at once. The dispatch check, explain and knowledge_at never use the cache.
resolved_at, profile_version, job_type, unconfigured_codesWhen the answer was made, which version of the profile made it, the job_type you sent, and any reason codes the profile had no rule for. A reason may also carry a detail, the source's own words.
attributionCredits owed for the sources that actually contributed. Where present, the statement must be shown wherever the answer reaches a person. It is a licence condition, not a courtesy.

POST /v1/availability:explain

The same answer with every constraint behind it, including the ones the profile dismissed as irrelevant: “we saw that and it does not affect your kind of work” is the claim most worth being able to challenge. Carries geometry, so it is capped at ten sites.

Some sources publish one obstruction again under a new id without withdrawing the first. Records identical in everything except their id (exactly the same geometry, times, description and severity) are counted once, in the windows as well as the list, so a republished restriction does not double its penalty. The other records are named in also_published_as, each with its own evidence_id, so every record we hold stays traceable. Two records alike in words but in different places are two obstructions and are listed twice.

Two fields are judged per source, because the same number means different things in different feeds. confirmed_times is true when the promoter has reported starting the works: the window then opens when they started, and closes when they reported finishing or, until they do, at the end they applied for. It is false when no start has been reported, and the window is the one applied for. It is absent for a source that never reports completion, such as National Highways, rather than false on every record, and for a Section 58 restriction, which runs for its stated term and has nothing to complete. implausible_extent is present, and true, when a constraint is longer than its source's works plausibly are: over a kilometre for a Street Manager permit, which usually means the whole street was published rather than the works. It is never set for a National Highways closure, whose geometry is the closed carriageway itself, or for an embargo, which covers a street by design.

provisional is present, and true, when the source has published a restriction but not confirmed it is in effect: a Section 58 restriction still proposed, or a traffic order that allows a road to be closed at any time over more than a month, with no days or hours (long_order). A provisional constraint is never resolved harder than soft, whatever the profile says of its type, so it cannot block a window. Where the profile would have made it hard had it been confirmed, blocks_if_confirmed is also present, and the dispatch check raises unconfirmed_restriction, or closure_order_any_time for a long order. A long order the profile would only have made soft is ignore.

supported_by is present on a permit when a traffic order describes the same works: the same kind of obstruction, overlapping in time, within 30 m. Works that close a road usually need both, and counted as two the closure would cost a job twice. So the permit is counted once, and each order behind it is listed with its constraint_key, source, type, detail, valid_from, valid_to and evidence_id, so it stays traceable. An order is folded only under a permit in the same answer, and a long order folded under a permit takes the permit's dates. An order with no permit beside it, such as an event or an authority's own works, is a constraint in its own right, with source dtro.

past_planned_end is present, and true, on a Street Manager permit counted after its planned end because its works started and the promoter has reported no stop or completion, so they may still be on site. It is a second entry for the same permit, with the same constraint_key: its valid_from is the planned end, and its valid_to three days later, after which it is no longer counted. The permit's own window is the other entry, without the flag, so a job across the planned end sees both. It is never resolved harder than soft, because nothing confirms the works are still there. Where the profile would have made it hard, blocks_if_still_on_site is also present, and the dispatch check raises works_past_planned_end. A stop or completion published later withdraws it at once.

distance_m is how far the constraint is from the site, nearest point to nearest point, in metres. A profile's rule for a type can reach less far than the site's distance: a Section 58 restriction bars digging in the street it names, so the built-in kinds count one only within 25 m. Beyond its rule's reach a constraint is ignore, and out_of_reach_m gives the reach it fell outside.

POST /v1/dispatch:check

A whole job list triaged. Same body as a query, plus:

{
  "horizon": { ... },
  "sites": [ ... ],
  "accept": ["unconfirmed_end_time"],
  "strict_coverage": false
}
{
  "summary": { "total": 9, "clear": 7, "queued": 2, "qualified": 9,
               "by_reason": { "blocked": 2, "coverage_gap": 9 } },
  "completeness": {
    "complete": false,
    "sources": [{ "source": "street_manager", "from": "2026-07-01",
                  "to": "2026-10-20", "asked_to": null,
                  "gaps": [{ "from": "2026-10-01", "to": "2026-10-18",
                             "days": 18, "working_days": 12 }] },
                { "source": "national_highways", "from": "2026-10-19",
                  "to": "2026-11-04", "asked_to": "2026-11-04",
                  "gaps": [] }]
  },
  "advisory_feed": { "source": "environment_agency", "kind": "flood_warnings",
                     "available": true, "checked_at": "2026-10-24T11:45:43Z" },
  "jobs": [{ "ref": "WO-1007", "clear": false, "qualified": true,
             "reasons": ["blocked", "coverage_gap"],
             "labels": ["Blocked during this slot",
                        "Permits may be missing"],
             "summary": "Blocked during this slot.",
             "advisories": [] }]
}

Each job also carries resolution_id, the logged answer that verdicts and decisions are recorded against (absent only if logging it failed); its windows, without confidence or reasons; buffer_m; restrictions, the traffic rules near it, and the response restriction_feed, as an availability answer carries them; attribution, the credits owed, which must be shown wherever the job reaches a person; and profile and profile_source, as an availability result does, so a list that mixes kinds of work says which rules each job was answered under. The dispatch check is never served from the cache. fallback means the profile or job_type asked for matched nothing: the job was answered with no rules, and anything near it comes back as unconfigured_rule rather than as a guess.

Reasons

CodeMeansAcceptable?
blockedA hard constraint overlaps the job.yes
unconfirmed_restrictionA Section 58 restriction that would stop this work if in force was proposed and never confirmed or withdrawn. Check with the highway authority.yes
closure_order_any_timeA traffic order allows the road to be closed at any time over these dates, with no days or hours, and no permit says when. It would stop this work on a day the road is closed. Check with the highway authority.yes
works_past_planned_endWorks that would stop this job started and were due to finish before it, and the promoter has reported no stop or completion. They may still be on site. Counted for three days past the planned end. Check with the promoter.yes
delayedWorkable, but something adds time.yes
unconfirmed_end_timeThe promoter has not reported starting the works, so they are shown over the whole period applied for (confirmed_times is false). Works often finish early and some never go ahead. The code is older than this reading and is kept as the contract: it is raised for a start not reported, and works started and not reported finished are works_past_planned_end once their planned end has passed. Not raised for National Highways, which publishes bookings and never completions, so the absence of one there tells you nothing.yes
implausible_extentPublished over a much longer stretch than the work is likely to occupy.yes
unrecognised_source_valueThe permit used a code we do not yet recognise.yes
unconfigured_ruleFound something this profile has no rule for.yes
coverage_gapThere are dates we received no data at all, or the site lies outside the area a source is held for, in which case the job carries not_held naming the source.no
beyond_horizonThe job runs past the last date a relevant source was asked about.no
Why coverage_gap and beyond_horizon cannot be accepted. Every other reason describes something we found, and a caller can weigh it because they can see what it is. These two describe something we were never sent. Nobody can weigh a risk whose size is unknown by definition, so both are always reported, can never be waived, and neither by itself queues a job. Pass strict_coverage to queue everything while a gap stands. Either one in accept refuses the whole request with 400 unacceptable_reason.
They are not the same thing and the remedy differs. A coverage_gap is a hole inside what we hold, in time or in place: permits issued on dates when no data reached us, or a site outside the area a source is held for. Either is closed by a backfill, so asking again now returns the same answer. beyond_horizon is the edge of the span: the job runs past the last date that source was asked about, which the same response reports as asked_to. National Highways serves a rolling fourteen-day window and publishes no archive, so a job three weeks out on a motorway is answered by a source that was never asked about those dates. That one is closed by re-running the check nearer the date.

A source with "asked_to": null has no forward limit and can never raise this. Both are tested against National Highways only where it has previously produced a constraint near the site, so a motorway feed does not caveat a job on a residential street. Street Manager covers every street, so a gap in it bears on every site where it is held.

qualified is independent of clear: “we found nothing” and “we may not have been told everything” are different statements, and a list can be nine-tenths clear and wholly qualified.

POST /v1/constraints:area

Everything in a map area over some dates, for a planner looking around before choosing a site. Not an answer about any site: no windows, no verdict, and nothing is logged as a resolution.

{
  "bbox": [-1.4760, 53.3790, -1.4660, 53.3830],
  "horizon": { "from": "2026-11-01T00:00:00Z", "to": "2026-11-08T00:00:00Z" },
  "profile": "excavation"
}

bbox is west, south, east, north in degrees; each side may be at most 5 km and the dates at most 31 days apart. profile is optional and decides each constraint's disposition, exactly as it would in an explanation; omitted, your default profile answers.

The response carries completeness, the area it answered (with the profile used), and constraints, each described exactly as availability:explain describes it, geometry included, blocking ones first, and within each weight Section 58 restrictions after other works, since they run for years. At most 500 are returned. advisories lists the flood warnings in force touching the box, each with its boundary clipped to the box and simplified for drawing; they are not counted in total. restrictions lists the traffic rules from traffic orders touching the box, each whole, with its geometry, as an availability answer describes them; at most 500, with restrictions_truncated present, and true, when some were left out, and restriction_feed beside them. total says how many constraints there were and truncated is true when some were left out: a cut map must never be drawn as if it were the whole one, so ask for a smaller area. attribution carries the credits owed, which must be shown wherever the map reaches a person.

POST /v1/verdicts

What was actually found. Every answer this service gives is a prediction; this is how one gets checked.

{
  "verdicts": [
    { "resolution_id": "5d0c3b9e-2f4a-4c1e-9a7b-3e8f1d2c6a40", "observed": "clear",
      "note": "Road open, works finished Tuesday" }
  ]
}
FieldNotes
resolution_idReturned per job by /v1/dispatch:check. Anchors the verdict to the exact answer logged, with the knowledge and rules that produced it, rather than to a job reference that means nothing to us.
observedobstructed, clear or unknown.
noteOptional, and the most useful field here when filled in.

This records what you found, not whether we were right. “We were right” is a judgement about an answer given on a particular day under rules that may since have changed, and it stops meaning anything when either moves. “The road was open” stays true and can be compared against whatever we would say today. Agreement is derived, never stored.

unknown is a real answer and is there on purpose. Without it, the honest response to an ambiguous check is to record nothing, and the data then over-represents the clear-cut cases, which are the ones least worth learning from. A second verdict on the same answer replaces the first.

Returns { "recorded": n, "ignored": n }. An id that matched nothing is ignored rather than refused: it is almost always an answer belonging to another tenant, and an error would confirm the id exists. An id that is not a UUID, which no answer has, refuses the whole send with 400 bad_resolution_id before anything is written. At most 500 per send; a note is kept to 2,000 characters.

POST /v1/decisions

What the dispatcher decided about each job, against the answer they were looking at. The planning screen sends these as they are made; a scheduler or your own tool can send them too. ADR 0019.

{
  "decisions": [
    { "id": "7794cd4f-f7f5-48a3-b3bb-b7d7781c88dc",
      "resolution_id": "5d0c3b9e-2f4a-4c1e-9a7b-3e8f1d2c6a40", "action": "move", "reason": "Road closed",
      "shown_status": "blocked",
      "moved_to": { "from": "2026-10-26T09:00:00Z", "to": "2026-10-26T11:00:00Z" },
      "decided_at": "2026-10-22T07:12:04Z" }
  ]
}
FieldNotes
idA UUID you choose. Send the same decision again with the same id and it is recognised, not recorded twice, so a retry after a dropped connection is always safe.
resolution_idReturned per job by /v1/dispatch:check: the answer the decision was made on.
actionready, move, hold or withdrawn.
reasonRequired, up to 500 characters. Clear for a job marked ready with nothing found.
shown_statusWhat the screen showed: go, time, check or blocked.
moved_toFor a move, and only a move: the new slot.
withdrawsFor a withdrawal, and only a withdrawal: the id of the decision withdrawn.
decided_atWhen the person decided, by their device's clock. Up to 31 days old, at most an hour ahead of ours.

Nothing is ever overwritten. A change of mind is a new decision with action: "withdrawn" naming the one it withdraws, so the record keeps “marked ready, then withdrawn when the re-check showed a closure, then held” rather than only the last word. A decision can be withdrawn once, and only by a decision about the same job.

A decision sent here carries no name. Decisions made on the planning screen are sent by the screen to its own path, /planning/decisions, which takes only a signed-in session (a key alone is refused there with 401 sign_in_needed), and the trail names the person signed in by their email, as decided_by. A name is only ever taken from a verified sign-in, never from the request body.

Up to 500 per send. A malformed send is refused whole with the reasons. Otherwise the answer is 200 with an outcome per decision: recorded, already_recorded (a resend), or refused with why, for example an answer this key was never given, which reads exactly like one that does not exist.

GET /v1/decisions?job_ref=WO-10391

The trail for one job: every decision on it, oldest first, each with the answer it was made on (resolution_id, when it was given, the slot it was for) and what was found against that answer, if anyone recorded it. A withdrawn decision says which decision withdrew it in withdrawn_by, and each says who made it in decided_by, or null when it did not come from a signed-in person. At most 200 are returned; truncated says when there were more.

POST /v1/watches

{
  "watch_ref": "WO-1001",
  "site": { "geometry": { "type": "Point", "coordinates": [-1.4701, 53.3811] },
            "job_type": "METER_EXCHANGE" },
  "horizon": { "from": "2026-11-03T08:30:00Z", "to": "2026-11-03T10:30:00Z" },
  "target_url": "https://scheduler.example.com/hooks/site-readiness"
}

Register a scheduled job. When the answer for it materially changes, a webhook fires with a sentence saying what changed, the windows lost, the reasons newly reported and the whole new answer, so there is nothing to call back for. It carries no flood warnings: they never change an answer, so they never raise an alert, and a webhook leaves them out. Ask the availability query for those. Its attribution carries the credits owed for the sources in that answer, which must be shown wherever it reaches a person, as with every other answer. Signed Stripe-style: t=<unix>,v1=<hex> over t + "." + body, HMAC-SHA256 with your watch secret, in the X-Thrive-Signature header. Verify it with a constant-time compare and reject anything more than five minutes old.

The first registration answers 201 with the signing_secret, shown once; registering the same watch_ref again updates it and answers 200 without it. target_url must be https, with no credentials in it. The webhook body's fields are camelCase: eventType (availability.changed), watchRef, verdict, summary, detectedAt, horizon, workableMinutesDelta, penaltyMinutesDelta, lostWindows, newReasons, windows and attribution. Answer 410 and the watch is paused.

Silence is not the same as no change. A watch is not evaluated while a hole in the data bears on its horizon, so that a hole cannot be baked into what it compares against. For a site outside the area we hold street works for, that is every sweep: such a watch stays silent until the area is held. Ask the dispatch check before the job as well.

POST /v1/ingest/street-manager/{permit|activity|section-58}

Not for you to call. These are the three endpoints Street Manager's notification service posts to, one per topic, and they are listed here because they appear on the service's own endpoint list and anyone auditing the surface should know what they are.

They take no bearer token, because SNS cannot present one. Authentication is the AWS message signature instead: the certificate URL is checked to be a genuine SNS host, the certificate is fetched and its public key extracted, and the signature is verified over the canonical message before any field of the payload is read, including the topic it claims to be from.

A subscription confirmation is answered only after that check passes. Calling a SubscribeURL from an unverified message would mean subscribing to whatever an attacker nominated.

GET /v1/health

Anonymous, and thin: up, whether the database answers, and whether the scheduled jobs are running (scheduled: ok; late when they have not run for over half an hour, which makes status degraded; or none where the deployment runs none, as staging does). With a valid key it also reports when each Street Manager topic last delivered, which says exactly when we are blind to the live feed: not something to serve to anyone who asks.

It answers 200 while the database answers, degraded or not, because the API still answers. With ?strict it answers 503 whenever status is not ok, for a monitor that can only tell up from down by the status code.

Rate limits

Per tenant, per minute, set on your key: 60, 120 (the default), 300, 600 or 1,000 requests a minute, counted across the query, explain, dispatch, area and watch endpoints, each of which answers with RateLimit-Limit, the limit applied. Verdicts, decisions and the decision trail are not counted. The count is kept close to where requests arrive and is approximate, so a burst can pass a little over the limit before it is refused. A 429 carries Retry-After, a full minute, and retry_after_seconds in its body. A query's answer is cached while the store is unchanged, and ten minutes at most, so repeated asks about the same site and horizon are cheap; point-in-time, explain and dispatch requests never read or write that cache.

AI tools over MCP

An AI tool can ask the service for a person, read-only, over the Model Context Protocol: Claude (as a custom connector) and Microsoft Copilot Studio (as an MCP tool) to start with, in the preview. Its address is POST /mcp. It takes no API key: the person signs in, and the tool asks as them, with their company, their role and their preview's allowance.

Connecting. Give the tool the address https://srs.thrivefsm.com/mcp. Asked without a token, it answers 401 with a WWW-Authenticate header naming /.well-known/oauth-protected-resource/mcp, which names the authorization server, WorkOS AuthKit. The tool registers there (a Client ID Metadata Document, or Dynamic Client Registration) and sends the person to sign in. They sign in on our own page with the emailed code, as always, see which company and address the tool will ask as, press Connect, and then allow the tool by name. A connection lasts up to seven days; then the person connects again. Removing a person, or the end of their preview, stops their tools at the next call.

The protocol. Streamable HTTP, one JSON-RPC message a request, answered as JSON, with no server session: initialize is answered for the 2025 revisions, and a request without it, as the 2026-07-28 revision sends them, is answered the same. MCP-Protocol-Version, where sent, is one of 2025-03-26, 2025-06-18, 2025-11-25 or 2026-07-28; another is answered all the same. There is nothing to GET, and batches are refused.

ToolWhat it answersNeedsCounted
check_jobsUp to a hundred jobs, each at its place and time: Can go, Adds time, Check or Blocked, with the reasons, as dispatch:check answers them.PlannerEach job is a check; the same job again the same day is free
check_siteOne place over some dates: which times are clear, add time or are blocked, and the next clear slot of a given length, as availability:query.PlannerOne check
explain_siteEverything registered at one place over some dates, what each does to the kind of work, and who to contact, as availability:explain.ViewerNo
look_aroundEverything within a distance of a point over some dates, as constraints:area.ViewerNo
kinds_of_workThe company's kinds of work, in words, and its own job codes.ViewerNo

Times are taken with an offset (2026-11-03T08:00:00Z), as UK local time (2026-11-03T08:00), or as a date, meaning the whole UK day. A place is checked, explained or looked around across at most 31 days. Each answer leads with one plain line in which any caveat is part of the verdict ("Can go, but not everything could be checked: ..."), says which dates and sources it could not check, carries the credits each source's licence asks for, and quotes what the registers published as data. The same answer is in structuredContent, its words first as answer, for a client that reads only that.

Limits, per person. Sixty calls a minute, approximately, and two hundred an hour, counted exactly, where each job in a list counts one and any other call one. A preview's used-up allowance stops checks from the next 03:00, as on the planning screen. A refusal inside a tool (the role, the allowance, the hour, or what was asked) comes back as a result with isError and the reason in words, so the tool can say it.

Refusals before a tool runs. 401 with sign_in_needed and, for a token that was sent but cannot be used, error="invalid_token" in WWW-Authenticate: none sent, an API key, expired, forged, for another resource, or a connection more than seven days old or withdrawn. 403 with not_invited, no_company, suspended, ended, closed or no_subscription as for a signed-in person. 405 method_not_allowed for anything but POST, 413 for a message over 256 kB, and 502 unavailable if AuthKit's metadata cannot be read for a client that asks this address for it.

Operated by Thri5 Project Consulting Ltd. Powered by National Highways' Transport Data Feeds. Street works data from the Department for Transport's Street Manager open data, and traffic orders from its D-TRO service. Contains public sector information licensed under the Open Government Licence v3.0. Flood warnings: this uses Environment Agency flood and river level data from the real-time data API (Beta).