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": "..." }] } }
| Status | Code | Means |
|---|---|---|
400 | invalid_request | The body
did not validate. details names each field and what was
wrong with it. |
400 | invalid_json,
too_many_sites, unacceptable_reason | A
body that is not JSON, more sites than one request takes, or a reason in
accept that can never be accepted. |
400 | no_verdicts,
too_many_verdicts, missing_resolution_id,
bad_resolution_id, bad_observed | A
verdicts send that is empty, over 500, or has an entry without a
usable id or a known observed. |
401 | unauthorized | Missing, unknown or revoked key. |
403 | account_suspended | The account is suspended. Contact us. |
404 | not_found,
unknown_topic | No such route, or a Street Manager topic that is not one of the three. |
413 | payload_too_large | Body over 1 MB. |
429 | rate_limited | Rate
limited. Retry-After says how long to wait. |
500 | internal_error | Something
failed on our side, including the database being unreachable. The
incident id finds the log line; quote it to us. |
503 | watches_unavailable | Only
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" }
}
]
}
| Field | Notes |
|---|---|
ref | Yours. Echoed back untouched; we hold no idea what it refers to. |
geometry | GeoJSON, WGS84, longitude first. Point, LineString or Polygon, or their Multi forms. |
buffer_m | Optional. Defaults to the profile's own. 1–1000. |
job_type | Optional. 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. |
profile | Optional, 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/" }]
}]
}
| Field | Notes |
|---|---|
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. |
status | clear,
degraded or blocked. |
penalty_minutes | Added 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. |
confidence | How 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_id | The payload this came from. Ask support and it can be produced. |
profile_source | explicit 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_at | Whether 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_codes | When 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. |
attribution | Credits 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
| Code | Means | Acceptable? |
|---|---|---|
blocked | A hard constraint overlaps the job. | yes |
unconfirmed_restriction | A 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_time | A 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_end | Works 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 |
delayed | Workable, but something adds time. | yes |
unconfirmed_end_time | The 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_extent | Published over a much longer stretch than the work is likely to occupy. | yes |
unrecognised_source_value | The permit used a code we do not yet recognise. | yes |
unconfigured_rule | Found something this profile has no rule for. | yes |
coverage_gap | There 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_horizon | The job runs past the last date a relevant source was asked about. | no |
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.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" }
]
}
| Field | Notes |
|---|---|
resolution_id | Returned 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. |
observed | obstructed,
clear or unknown. |
note | Optional, 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" }
]
}
| Field | Notes |
|---|---|
id | A 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_id | Returned per job by
/v1/dispatch:check: the answer the decision was made on. |
action | ready, move,
hold or withdrawn. |
reason | Required, up to 500 characters.
Clear for a job marked ready with nothing found. |
shown_status | What the screen showed:
go, time, check or
blocked. |
moved_to | For a move, and only a move: the new slot. |
withdraws | For a withdrawal, and only a withdrawal: the id of the decision withdrawn. |
decided_at | When 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.
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.
| Tool | What it answers | Needs | Counted |
|---|---|---|---|
check_jobs | Up 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. | Planner | Each job is a check; the same job again the same day is free |
check_site | One 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. | Planner | One check |
explain_site | Everything registered at one place
over some dates, what each does to the kind of work, and who to contact,
as availability:explain. | Viewer | No |
look_around | Everything within a distance of a
point over some dates, as constraints:area. | Viewer | No |
kinds_of_work | The company's kinds of work, in words, and its own job codes. | Viewer | No |
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).