Byggradarn

Text: /developers/api.md

The Byggradarn API

This is the contract for calling the platform. It is served by the platform itself at /developers (rendered) and /developers/api.md (this text), and it is binding: a test walks every route the platform maps under /api and fails the build if one is missing here, or if this document names one that does not exist. If the code and this document disagree, the code is right and the document is a bug — fix both in the same change.

Written for a person building an app on the platform, and for an agent doing the same on their behalf. Everything an app needs is on this page; the links out are for why, never for how. How an app should look is the other document on this portal, /developers/design. Every route below, as the platform actually maps it, grouped by the scope that unlocks it and linked back into this text, is at /developers/endpoints — read from the code at request time, never written by hand.

Conventions

  • Base URL. The deployment's origin, e.g. https://platform.example/. Paths below are relative.
  • JSON in, JSON out. Content-Type: application/json; charset=utf-8 on every body. Field names are camelCase. Unknown fields in a request are ignored.
  • Times are ISO-8601 instants in UTC: "2026-09-18T07:12:44.120Z". Dates are "2026-09-18".
  • Money is a decimal string or number of SEK unless a field says otherwise; never a float in our storage, so send what you mean.
  • Identifiers (id) are integers. Keys (caseKey, sourceKey, datapoint key) are strings.
  • Swedish stays Swedish. Datapoint keys are the domain's own words — kommun, upphandlings_id, fastighetsbeteckning, budget — and interface text the platform returns (notification titles, warnings) is in Swedish.
  • Nothing is deleted at ingest and nothing is deduplicated across sources. Re-posting the same event is a no-op, not an error.
  • Any origin may call. /api answers cross-origin requests from anywhere, with no credentials flag: a browser app on its own domain calls the API directly with its token, and never a cookie. The provider's token endpoint allows the app's own origins the same way.

Authentication

Every call under /api carries a credential: an API key, or a token from the platform's identity provider.

X-Byggradarn-Key: <key>
Authorization: Bearer <key>
Authorization: Bearer <token>

A missing or unrecognised credential answers 401. One that is recognised but may not reach the endpoint answers 403. Keys are shown once, when issued, and stored hashed; there is no way to read one back. The platform tells a token from a key by shape — a token has two dots, a key has none — so both go in the same header.

Tokens

A token is a JWT issued by the deployment's identity provider (Keycloak) and validated against its published keys; the platform never calls the provider on a request. A deployment with no provider configured refuses every token with 401 and a message that says so, and keys work as before. The claims the platform reads:

Claim Means
sub the person, or the service account. The one fact about a person the platform keeps
azp the app the token was issued to
scope space-separated scopes, see below
organization Keycloak's organization-membership claim, keyed by the organization's alias: { "uppsala": { "id": "…", "apps": ["eu-navigator"] } }. The alias is the customer account; apps is the account's attribute listing the apps it holds. With an organization present, azp must be among apps or every call is 403 — an app the account was never given is refused whatever its scopes. Absent on a token for a machine client

An app signs a person in through the provider and sends the access token it receives; a backend with no person behind it uses client credentials and gets a token with sub and no organization. The scopes an app may request are set when the app is registered, and changed on its page on /admin/appar afterwards; a sign-in that asks for a scope the app does not hold is refused by the provider with invalid_scope, naming the whole request rather than the one scope.

Registering an app

An administrator registers the app on the platform's /admin/appar page — never in the provider's own console — as one of two kinds. A browser app (a Next.js page, a Vercel deployment) is a public client with PKCE: it has a key such as eu-navigator, one or more redirect URIs, and the scopes it may ask for; it has no secret, because a browser cannot keep one. A backend is a confidential client: the same, plus a secret shown once at registration and never again. Both kinds get the provider's own claim scopes as defaults, so every token they are issued carries sub, email, name and organization whatever else they ask for.

Then the administrator makes the customer's account on /admin/konton — an alias such as uppsala, a name, whether it is a kommun or a company — ticks the apps it holds, and invites its people by e-mail. A person belongs to one account.

Signing a person in — a browser app

The provider is an ordinary OpenID Connect issuer, and everything an app needs is in its discovery document:

GET <issuer>/.well-known/openid-configuration
→ { "authorization_endpoint": "…", "token_endpoint": "…", "jwks_uri": "…", "end_session_endpoint": "…", … }

The flow is authorization code with PKCE, which every OIDC client library implements; an app with one does not need the rest of this section. By hand:

  1. Make a code_verifier (43–128 random URL-safe characters) and its code_challenge (base64url of its SHA-256).
  2. Send the person to authorization_endpoint with response_type=code, client_id=<app key>, redirect_uri=<one of the app's>, scope=openid <the platform scopes wanted>, code_challenge, code_challenge_method=S256 and a random state.
  3. They come back to the redirect URI with ?code=…&state=…. Check state.
  4. POST token_endpoint as a form: grant_type=authorization_code, code, redirect_uri, client_id, code_verifier{ "access_token": "…", "refresh_token": "…", "expires_in": 300, … }.
  5. Call the platform with Authorization: Bearer <access_token>. When it expires, POST token_endpoint with grant_type=refresh_token; when the person leaves, send them to end_session_endpoint with id_token_hint and a post_logout_redirect_uri — any of the app's redirect URIs, which are registered for that too — so the provider's session ends and not only the app's.

Ask only for the scopes the screen needs — scope=openid me funding:read for a page that shows who is signed in and the calls — and the token carries exactly those. A scope the app was not registered with is refused by the provider at step 2.

A backend — client credentials

POST <issuer>/protocol/openid-connect/token
grant_type=client_credentials&client_id=<app key>&client_secret=<secret>&scope=ingest funding:read
→ { "access_token": "…", "expires_in": 300 }

The token has sub and no organization, so it is entitled to nothing per account and everything its scopes open; it cannot reach me or subscriptions. Cache it until shortly before expires_in.

What a token posts under

Everything a token writes through POST /api/ingest is filed under the source app:<azp>@<alias> — the app, at the account — or app:<azp> for a backend with no account. The platform names the source from the token, never from the payload, so two customers using the same app are two sources, and a case created that way belongs to the account (Cases — whose it is). An INGEST key with a sourceKey does the same for a connector.

What the platform is, and is not, told

The platform reads the token's claims and nothing else: it never calls the provider on a request and holds no copy of the person beyond sub, their e-mail and name. What an account holds, who is in it and what an app may ask for all live in the provider and change there, through the admin pages, with effect on the next token issued.

Trying it without a provider. Under the dev and e2e profiles the platform is its own issuer at its base URL — /.well-known/openid-configuration says the rest. Two apps are seeded: e2e-app, a browser app with PKCE that may redirect to /developers, and e2e-machine, a backend with the secret e2e-not-a-secret; the platform's own sign-in is the login, and the seeded account exempelstad holds e2e-app. Apps registered on /admin/appar are clients too. None of this exists under any other profile, in code.

The first token for a person makes them known to the platform, from sub, email and name: from then on their subscriptions, notifications and keys are theirs. An account made with a password before there was a provider is the same person when the provider says the address is verified (email_verified); a token for that address that is not verified is refused with 401, because anybody who could register the address would otherwise inherit the account. A machine client has no person and cannot reach me or subscriptions.

Scopes

Every endpoint requires one scope, named beside its heading below, and a call is refused with 403 unless the credential carries it. A key has one role, and the role is a fixed set of scopes:

Scope What it opens
me the caller's own profile and keys
cases:read cases, their history, and the datapoint catalogue
subscriptions the caller's subscriptions
notifications:read what the platform told the caller, and how they want to be told
notifications:write raising a notification to people in an account the caller may address — ADR 0045
funding:read EU programmes, open calls and awards — public facts about money
runs:read what every source's runs say: when data was last refreshed, and whether it is
ingest writing what a source found: events, discovery, funding, runs, outbound letters
tasks claiming work and answering it; nothing else
admin issuing keys, releasing letters, changing the catalogue, replaying an event
Role Who holds it Scopes
USER a person, or an app acting for one me, cases:read, subscriptions, notifications:read, funding:read, runs:read
INGEST one connector or one app that posts data ingest, plus everything USER holds
WORKER a worker that runs a model tasks, and nothing else
ADMIN an administrator every scope

A token carries its scopes in the scope claim; the endpoints do not care which arrived.

An INGEST key carries a sourceKey. Everything it posts to /api/ingest is filed under that source, and the platform never asks the caller which source it is. One key per connector or app: two callers sharing a key are indistinguishable in the data forever.

Health

GET /api/health{ "status": "up" } with 200, or { "status": "down" } with 503, and nothing else: whether the instance can reach its database. Public, uncounted by the doors, and what a deployment's healthcheck asks. Everything else about health is on the console.

The front door's signals

GET /api/front/signals — the platform's own newest cases that carry a kommun, placed on it:

{ "asOf": "2026-09-21T10:00:00Z",
  "signals": [ { "kommun": "Uppsala", "title": "Järlåsa förskola", "skede": "Förstudie", "lat": 59.945, "lon": 17.716, "when": "…" } ] }

Public and uncounted by the doors, like health, and cheap by construction: computed at most every five minutes and held in memory, Cache-Control: max-age=300, public. At most forty, never a customer's own project, never a kommun the platform cannot place. It exists for the front door's radar (ADR 0055) and is not a substitute for GET /api/cases: no filter, no paging, no values beyond the four above.

Getting a key

POST /api/auth/register
{ "email": "anna@example.se", "password": "at least twelve characters", "displayName": "Anna" }
→ 201 { "user": { …UserResponse }, "apiKey": { "id": 12, "name": "…", "role": "USER", "secret": "brk_…", "createdAt": "…", "expiresAt": null } }

POST /api/auth/login
{ "email": "anna@example.se", "password": "…", "keyName": "my app on my laptop" }
→ 200 { "user": { … }, "apiKey": { …, "secret": "brk_…" } }

Both are the only unauthenticated endpoints, and both are rate-limited by client address (below). login issues a new key every time; name it so it can be recognised and revoked on /api/me/api-keys. INGEST and WORKER keys are issued by an administrator (see Administration).

What is coming

The platform's own web sign-in through the same provider, so a person has one login for the product and for every app. Keys keep working for connectors and workers after.

Errors

Every error is the same shape:

{ "status": 404, "error": "Not Found", "message": "Case 9 not found", "fields": {}, "timestamp": "2026-09-18T07:12:44.120Z" }

fields is filled on 400 validation failures, keyed by field name. Otherwise message is the whole story, in English for the API.

Status Means
400 the body did not validate, or was not JSON; message or fields says which field
401 no credential, a key nobody issued, or a token nobody signed
403 the credential lacks the endpoint's scope, or the row belongs to somebody else
404 no such row, or one the caller may not see (the two are deliberately the same)
409 a conflict — a key name already taken, a task already answered
413 the body is over the cap
429 rate limited; Retry-After says when
500 ours; the message is generic and the detail is in our log

Rate limits and body size

Door Counted by Limit
POST /api/auth/login client address 10 a minute
POST /api/auth/register client address 5 an hour
any unauthenticated call under /api client address the sign-in limit
any keyed call under /api the key 600 a minute
any request body under /api 4 MB
a report — POST /api/funding/calls, POST /api/funding/awards, POST /api/discovery 64 MB

A report is the whole register by design and cannot be split, which is why its door is wider. A body over its cap is 413 whether it declared its length or not.

A refused call is 429 with Retry-After in seconds. Bursting up to the limit is fine; the bucket refills at the rate stated. The numbers are configuration, not secrets, and may be raised for a deployment.

Pagination

Endpoints that page take page (0-based, default 0) and size (default 20) and answer a page:

{ "content": [ … ], "totalElements": 128, "totalPages": 7, "number": 0, "size": 20,
  "first": true, "last": false, "numberOfElements": 20, "empty": false }

Read content, totalElements and totalPages; the rest is Spring's and may change.

Endpoints

Me and my keys — me

GET /api/me — who the key belongs to.

{ "id": 4, "email": "anna@example.se", "displayName": "Anna", "role": "USER",
  "notifyInApp": true, "notifyEmail": true, "createdAt": "…" }

PATCH /api/me/preferences — any subset of { "notifyInApp": bool, "notifyEmail": bool, "displayName": string }. Answers the updated user. Everything the platform's own settings page can set, so an app's settings screen is this call.

GET /api/me/api-keys — the caller's keys, without secrets:

[ { "id": 12, "name": "my app", "role": "USER", "sourceKey": null, "keyPrefix": "a8k2m0p4qz",
    "active": true, "createdAt": "…", "expiresAt": null, "lastUsedAt": "…" } ]

POST /api/me/api-keys{ "name": string, "expiresAt": instant | null }201 with the key including secret, once.

DELETE /api/me/api-keys/{id}204. Revoking the key that made the call works, and the next call answers 401.

The datapoint catalogue — cases:read

What the platform will accept and what each value means. Read this once and cache it: keys are stable, and a value posted under a key not in the catalogue is dropped with a warning rather than stored.

GET /api/definitions

[ { "id": 7, "key": "budget", "displayName": "Budget", "description": "…", "valueType": "MONEY",
    "identityKey": false, "scopeKey": null, "vocabularyKind": null, "terms": [], "active": true,
    "createdAt": "…" },
  { "id": 3, "key": "diarienummer", "displayName": "Diarienummer", "valueType": "REFERENCE",
    "identityKey": true, "scopeKey": "kommun", … },
  { "id": 9, "key": "skede", "valueType": "TEXT", "vocabularyKind": "ORDINAL",
    "terms": ["planerad", "forstudie", "program", "projektering", "upphandling", "genomforande"], … } ]
  • valueType is one of TEXT, REFERENCE, NUMBER, MONEY, DATE, BOOLEAN.
  • identityKey: true means a value under this key decides which case an event belongs to. scopeKey names the key that scopes it: diarienummer is unique per kommun, so an event that carries one must carry kommun too.
  • vocabularyKind is set for keys whose values are a controlled list; terms is that list, in order for ORDINAL. Synonyms are resolved on the way in, so "förstudie" and "forstudie" land as the same term.

POST /api/definitionsadmin. { "key": "[a-z0-9_]+", "displayName": string, "description": string, "valueType": …, "identityKey": bool }201 the definition.

Cases — cases:read

A case is one project as the platform currently understands it: the values it holds, the documents they came from, and the events that brought them.

Whose it is. A case an app posts under a token is that customer account's: the account reads it, the people who run the platform read it, and no other account can — not in the list, and not by id, which answers 404 exactly as a case that does not exist. The platform's own cases — crawled, mailed — are everybody's. The account is the token's, never the payload's.

GET /api/cases?query=&kommun=&kategori=&skede=&minVarde=&maxVarde=&early=&nya=&stanger=&egna=&sort=&direction=&page=&size= — paged CaseSummary, the search screen in one call. query matches the key, the title and any current value, case-insensitively; the rest narrow it and combine with AND:

Parameter Narrows to
kommun, kategori, skede cases whose current value equals it — a facet value from /api/cases/facets
minVarde, maxVarde kontraktsvarde above / below, typed the way a person types money: 10 Mkr, 4500000
early=true every phase before upphandling — the one shortcut the incumbents cannot answer
nya=true published in the last seven days; stanger=true — tender deadline in the next seven. The same windows /api/cases/stats counts, so a figure and its list agree
egna=true the account's own cases alone — what it posted through ingest, its projektbank — and nothing the platform crawled. A credential with no account is 403, as for the portfolio
sort senast (the case's own activity, the default), titel, kopare, cpv_kod, kontraktsvarde, sista_anbudsdag; direction is desc (default) or asc. Cases without a value for the sort key come last either way

size is capped at 100 everywhere that pages. Each row carries values, the current values by key as written, and fundingMissing, the matcher's inputs the case lacks — what funding-readiness answers, already on the row — so a list renders without a call per row:

{ "id": 41, "caseKey": "diarienummer=ksn-2026-01234@uppsala", "title": "Gottsundaskolan, ombyggnad",
  "documentType": "investeringsplan", "createdAt": "…", "updatedAt": "…",
  "values": { "kommun": "Uppsala", "kategori": "Skola", "skede": "Projektering", "budget": "120 mnkr",
              "sista_anbudsdag": "2026-11-01" },
  "fundingMissing": ["tema", "beskrivning", "planerat_fardigstallande", "internationell_partner"] }

GET /api/cases/facets — what each filter can be set to: kommun, kategori and skede, each a list of { "value": "uppsala", "label": "Uppsala", "count": 412 }. A vocabulary's terms come in the catalogue's order with count: 0; free values come from the data, most common first, at most forty.

GET /api/cases/stats?days= — the overview's numbers over the caller's cases, days (default 14, at most 90) of history per day:

{ "total": 3120, "newThisWeek": 48, "closingSoon": 17, "totalValue": 41200000000, "newDelta": 12,
  "perDay": [ { "date": "2026-09-06", "count": 3 }, … ] }

newDelta is the change against the previous seven days in per cent, null when that week had nothing to compare with. Numbers, not strings: an app formats them.

GET /api/cases/{id}/events — the events behind the case, newest first, as the events element below. GET /api/cases/{id}/documents — the documents its values came from, as the documents element below: what is known about each, not its bytes, which are still on the poller's disk until the document endpoint ADR 0037 decided exists.

GET /api/cases/{id} — the case in full:

{ "summary": { …CaseSummary },
  "datapoints": [ { "id": 901, "key": "budget", "displayName": "Budget", "valueType": "MONEY",
                    "value": "120 mnkr", "normalizedValue": "120000000", "confidence": 0.92,
                    "evidence": "Investeringsram 120 mnkr 2027–2029", "sourceFilename": "plan.pdf",
                    "observedAt": "…" } ],
  "documents": [ { "id": 55, "filename": "plan.pdf", "contentType": "application/pdf",
                   "sizeBytes": 812233, "sha256": "…", "storagePath": "…", "createdAt": "…" } ],
  "events": [ { "id": 300, "externalId": "ted:2026/S 123-456789", "sourceType": "API",
                "sourceKey": "ted", "origin": "…", "subject": "…", "occurredAt": "…",
                "ingestedAt": "…", "agentName": "ted-mapper", "warnings": null } ] }

datapoints holds the current value per key. value is as observed; normalizedValue is what comparisons and subscriptions use. evidence is the sentence or cell it came from, and is there so every figure on a screen can be checked.

GET /api/cases/{id}/changes?page=&size= — paged, newest first:

{ "id": 77, "caseId": 41, "caseKey": "…", "key": "budget", "displayName": "Budget",
  "changeType": "CHANGED", "previousValue": "95000000", "newValue": "120000000", "detectedAt": "…" }

changeType is ADDED (first value) or CHANGED.

GET /api/cases/{id}/datapoints/{definitionId}/history — every value ever observed for one key on one case, newest first, as the datapoints element above. definitionId is the catalogue id.

GET /api/cases/{id}/funding-readiness — which of the matcher's inputs the project has, and which it lacks. The POC's "fält saknas för bästa matchning", from one list the matcher and this share:

{ "caseId": 41, "present": ["tema", "budget", "kommun"],
  "missing": ["beskrivning", "planerad_byggstart", "planerat_fardigstallande", "internationell_partner"],
  "complete": false }

A missing key is filled by posting it through ingest like any other value.

GET /api/cases/{id}/funding-awards — the awards in the Commission's register the platform believes are this project, best first, each with its source and the evidence. A kommun that mails us its own register of EU projects gets each row as a case; the rows Kohesio also carries are joined to it here — a claim on title, beneficiary and year, never a key (eu-funding.md):

[ { "award": { "externalId": "Q7421387", "source": "kohesio", "title": "AI-driven återvinning",
               "beneficiary": "Exempelstad kommun", "euAmount": 8180604, "totalAmount": 21851994,
               "startsOn": "2026-01-12", "endsOn": "2029-01-12", "url": "https://kohesio.ec.europa.eu/…",
               "observedAt": "…" },
    "method": "normalised_match", "status": "confirmed", "confidence": 1.000,
    "evidence": "Titel: AI-driven återvinning; mottagare: Exempelstad kommun; start: 2026-01-12",
    "linkedAt": "…" } ]

status is confirmed at 0.9 and above — the title, and the beneficiary naming the kommun — and proposed below it, which an app shows as a possibility rather than a fact. An empty list says nothing about the project: it is our coverage, not their history (ADR 0030).

GET /api/cases/{id}/funding-matches — every open call scored against the project, best first, with the facts each score rests on:

[ { "caseId": 41, "caseTitle": "Energieffektivisering av 14 skolor", "identifier": "LIFE-2026-SAP-ENV-GOV",
    "title": "…", "programmeKey": "LIFE", "deadlineAt": "2026-12-01T16:00:00Z", "score": 0.600,
    "reasons": [ { "type": "TEMA_MATCH", "detail": "Programmet finansierar temat energi", "positive": true },
                 { "type": "KEYWORD", "detail": "Beskrivningen delar 1 ord med utlysningen: energieffektivisering", "positive": true },
                 { "type": "DEADLINE_IN_DAYS", "detail": "84 dagar till deadline", "positive": true } ],
    "computedAt": "…" } ]

type is one of TEMA_MATCH, TEMA_MISMATCH, KEYWORD, PARTNERSHIP_REQUIRED, DEADLINE_IN_DAYS; show a positive one as a tick and a negative as a warning, and the detail as the line under the bar. The score is made of these and nothing else — deterministic, the same facts scoring the same every run. A project with no tema has no ranking. No kronor: the match says which call fits and why, never what the EU would pay.

The best call, when its score is at or above the platform's threshold, is also written on the case as the value eu_matchning — the identifier as the value, the score and reasons as the evidence, under the source funding-matcher. It is the one datapoint the platform concludes rather than reads, and it is what a subscription watches: subscribe to eu_matchning and the ordinary notification is the radar, sent when the best fit changes and not otherwise.

Prospects — cases:read

GET /api/prospects?size= — what Möjligheter shows: every case with a stated phase, earliest first, and how many steps it is from being procured. The list that makes the product's case; only the cases the caller may see, at most 200.

[ { "caseId": 41, "title": "Gottsundaskolan, ombyggnad", "kommun": "Uppsala", "phase": "Förstudie",
    "phaseOrdinal": 20, "stepsToMarket": 3, "plannedStart": "2027-03-01", "amount": "120 mnkr",
    "category": "Skola" } ]

stepsToMarket is the ladder the reader sees — the phases between this one and upphandling — never an invented lead time in years; above zero, nobody has been asked to bid yet.

Signals — cases:read

GET /api/signals — what Investeringar shows: every kommun the platform can say something about, largest external financing need first, made of the cases the caller may see.

[ { "kommun": "Uppsala", "kommunValue": "uppsala", "volume": 4100000000, "externalNeed": 1800000000,
    "selfFinancingPercent": 56, "ratioYear": "2024",
    "timing": [ { "year": 2027, "volume": 900000000, "projects": 6 }, { "year": null, "volume": 200000000, "projects": 2 } ],
    "categories": [ "Skola", "VA" ], "projects": 14, "projectsWithoutAmount": 3,
    "contactName": "…", "contactEmail": "…" } ]

externalNeed is the volume less what the kommun's självfinansieringsgrad covers, null when that ratio is unknown — an unknown share is not a zero one. A timing slice with year: null is work whose start nobody has stated.

GET /api/signals/{kommun} — one kommun by either spelling, with the projects its figure is made of, largest first: { "signal": { …as above }, "projects": [ { "caseId": 41, "title": "…", "amount": 120000000, "plannedStart": "2027-03-01", "category": "Skola" } ] }. 404 for a kommun the platform holds nothing about.

Subscriptions — subscriptions

A subscription says what a person wants to be told about. Three shapes, by what is set:

  • One case: caseId — any change on that case.
  • One key everywhere: datapointKey — any change to that key on any case.
  • A filter: predicates — a change on any case matching all of them.

GET /api/subscriptions — the caller's, as SubscriptionResponse:

{ "id": 5, "app": "eu-navigator", "datapointKey": "budget", "datapointName": "Budget",
  "caseId": null, "caseKey": null,
  "predicates": [ { "datapointKey": "kommun", "datapointName": "Kommun", "operator": "IN",
                    "values": ["Uppsala", "Knivsta"] } ],
  "label": "Budgetar i Uppsala och Knivsta", "notifyInApp": true, "notifyEmail": false,
  "active": true, "createdAt": "…", "kind": "WATCH", "view": null }

app is the app the subscription was made from — the token's azp, read by the platform and never sent — and it is the app every notification the subscription produces belongs to (ADR 0045). Null for one made with a key, whose notifications every app shows.

kind tells the two named shapes below from a hand-built one: FOLLOW is pinned to a case with no conditions, SAVED_VIEW is conditions a search screen can express — and then view is the query that reopens it, in the fields the case search takes: { "kommun": "uppsala", "kategori": "", "skede": "forstudie", "minVarde": "", "maxVarde": "", "early": true, … } — and WATCH is anything else. A follow made on the platform's own page and one made here are the same row, and indistinguishable in the fan-out.

Follow a casePOST /api/subscriptions/follow { "caseId": 41 }201 the subscription, labelled with the case's title; following twice answers the one follow. 404 for a case the caller may not see. DELETE /api/subscriptions/follow/{caseId}204 removes the follow and only the follow — a watch narrowed to one datapoint on that case is a different thing and stays.

Keep a searchPOST /api/subscriptions/views { "label": "Tidiga förskolor i Uppsala", "kommun": "uppsala kommun", "kategori": "förskola", "skede": "", "minVarde": "", "maxVarde": "", "early": true }201 the subscription with kind: SAVED_VIEW. The fields are the search's; label is optional and is written from the conditions when absent (Uppsala kommun · Förskola · tidiga skeden). nya and stanger are not saved: they are windows relative to today, and a kept condition holds absolute dates. A view that narrows nothing is 400.

POST /api/subscriptions201:

{ "datapointKey": "budget", "caseId": null,
  "predicates": [ { "datapointKey": "kommun", "operator": "IN", "values": ["Uppsala"] },
                  { "datapointKey": "budget", "operator": "GREATER_THAN", "values": ["50000000"] } ],
  "label": "…", "notifyInApp": true, "notifyEmail": true }

Operators and how many values each takes: EQUALS 1, IN 1 or more, CONTAINS 1, GREATER_THAN 1, LESS_THAN 1, BETWEEN 2, EXISTS 0. Up to 20 predicates. Values compare against normalizedValue, so send 50000000, not 50 mnkr. notifyInApp and notifyEmail default to the user's preferences when omitted.

PATCH /api/subscriptions/{id} — any subset of { predicates, label, notifyInApp, notifyEmail, active }.

DELETE /api/subscriptions/{id}204.

Notifications — notifications:read

What the platform, or an app, told this person, on the in-app channel. Email and a webhook are separate deliveries of the same notification and are not listed here.

Whose notifications. Under a token issued to an app, the list is that app's notifications and the ones that belong to no app — the platform's own — and never another app's. Which app a notification belongs to follows from what caused it: the app that raised it, or the app whose subscription produced it. Under a key, which has no app, the list is everything (ADR 0045).

GET /api/notifications?category=&unreadOnly=false&page=&size= — paged, newest first:

{ "id": 810, "origin": "PLATFORM", "app": "eu-navigator", "category": "CHANGE",
  "target": { "kind": "case", "id": "41" }, "href": null, "actions": [],
  "caseId": 41, "caseKey": "…", "datapointKey": "budget", "changeCount": 1,
  "channel": "IN_APP", "status": "DELIVERED", "title": "Budget ändrad: Gottsundaskolan",
  "body": "95 mnkr → 120 mnkr", "createdAt": "…", "readAt": null,
  "actedAt": null, "actedAction": null }
Field What it is
origin PLATFORM or APP: who raised it
category CHANGE (a document moved a value), ALERT (a date arrived), PLATFORM (the platform itself), APP (an app told its people something) — and what ?category= filters on
app the app it belongs to; null is the platform's own, shown by every app
target what to open: { kind, id }. The platform emits case, funding-call and kommun; an app may use any kind of its own. The app turns it into a route; the platform never sends an app's URL
href a link the raising app gave, under its own registered address; null unless an app set it
actions what to render as buttons, in order: [ { "key": "open", "label": "Öppna ansökan", "target": null, "href": null } ]
datapointKey, changeCount for CHANGE: the datapoint when it is about one, and how many it bundles
actedAt, actedAction what the person did, as an app reported it below

GET /api/notifications/unread-count{ "unread": 3 } — the same scope as the list.

POST /api/notifications/{id}/read204.

POST /api/notifications/{id}/acted{ "action": "open" } → the notification. The app says which action the person took — one of actions[].key, or open for the row itself — so a second device shows it done. The first report wins; it is read from then on. Idempotent.

POST /api/notifications/read-all{ "unread": 0 } — the rows this app shows, not another's.

A notification that belongs to another app is 404 from all four, not 403: the id says nothing.

Settings — notifications:read

How this person wants to be told — one object, read and written whole, so an app's settings screen is a form over it.

GET /api/me/notifications/settings:

{ "channels":   { "inApp": true, "email": true },
  "categories": { "APP": { "inApp": true, "email": false, "webhook": true } },
  "apps":       [ { "app": "eu-navigator", "category": null, "inApp": true, "email": false, "webhook": true } ] }

PUT /api/me/notifications/settings — the same object → the same object. A switch left out is its default (on); a category or app row left out is gone. channels is account-wide and is what PATCH /api/me/preferencesme — has always set; categories is keyed by CHANGE, ALERT, PLATFORM, APP; an apps row names an app the account holds and, optionally, one category in it.

Each level can only narrow. A channel fires when the account, every category and app row that covers the notification, and the subscription all allow it — so turning email off on the account silences everything at once, and turning it off for one app silences that app and no other. Nothing below the account can switch on what the account switched off (notifications.md, How preferences combine).

One click to stop

Every notification mail carries List-Unsubscribe and List-Unsubscribe-Post headers, so a mail client's own unsubscribe button works without a sign-in (RFC 8058). The link is POST /api/notifications/unsubscribe?token=…: open, the signed token is the credential, and all it does is set channels.email to false for that one person → 204; a token that is not ours → 400. Counted at the door like the other open calls.

Raising a notification — notifications:write

An app tells its people something: a deadline, a comment, a call that fits. Not a write path for a source — it opens no case and produces no event; something that happened to a project goes to POST /api/ingestingest — and the fan-out notifies. This is for something to say to a person (ADR 0045).

POST /api/notifications201:

POST /api/notifications
{
  "recipients": { "subs": ["8f3c…", "a01b…"] },          or  { "account": "uppsala" }
  "title": "Ansökan till LIFE-2026 stänger om 3 dagar",
  "body": "Solcellsparken saknar budgetbilaga.",
  "target": { "kind": "application", "id": "4711" },
  "href": "https://eu-navigator.example/ansokan/4711",
  "actions": [ { "key": "open", "label": "Öppna ansökan" } ],
  "dedupeKey": "deadline-3d:4711",
  "channels": ["IN_APP", "EMAIL"]
}
→ 201 { "created": 4, "deduplicated": 0, "unknown": 1 }
Field Rule
recipients exactly one of subs (up to 500 subjects) or account (an alias: everyone whose last token was under it)
title up to 500 characters; the subject line
body up to 4 000; plain text
target optional; any kind the app defines, and an id in it
href, actions[].href optional; must be under one of the app's registered addresses — the origins of its redirect URIs — or the call is 400
actions up to 5, each with a key and a label; optional target and href
dedupeKey optional; the same key from the same app to the same person is one row, so a job that runs hourly says each thing once. deduplicated counts the rows it did not make
channels optional; a subset of IN_APP, EMAIL, WEBHOOK. Omitted means every channel the person allows

The app, the origin and the category are not in the body. A token issued to an app raises as that app, origin APP, category APP; a key raises as the platform, category PLATFORM, and the notification belongs to no app. The person's preferences are applied at raise time, so an app cannot send email to somebody who turned email off.

Who may address whom follows from the token, never from a field:

Caller May address Otherwise
a browser app under a person's token — an account on it members of that account 403
a backend under client credentials — an app, no person behind the token any account that holds the app 403
an administrator's key anybody

A subject nobody has signed in with is skipped and counted in unknown, not refused: the app's list of people is the identity provider's, and the platform's mirror lags it by a sign-in.

A backend's channel: the webhook — notifications:write

A backend app can take delivery of every notification that belongs to it — what it raised, and what the platform raised through subscriptions made from it — as a signed POST, and do with it what the platform does not know how to: push to a device, post to Slack or Teams, bundle a digest. The platform never learns about devices.

PUT /api/apps/{key}/webhook{ "url": "https://backend.example/byggradarn" }200:

{ "app": "eu-navigator", "url": "https://backend.example/byggradarn", "secret": "whs_…" }

https to a public address, or http on localhost; a literal private or link-local address is refused. The secret is shown once; registering again replaces both the address and the secret. DELETE /api/apps/{key}/webhook204. Either call is for an administrator's key or the app's own backend — a client-credentials token issued to {key}; a person's token is 403.

What arrives. POST to the URL, Content-Type: application/json, the body a NoticeDelivery:

{ "id": 810, "recipient": "8f3c…", "origin": "APP", "app": "eu-navigator", "createdAt": "…",
  "notice": { "category": "APP", "title": "…", "body": "…", "target": { "kind": "application", "id": "4711" },
              "href": "…", "actions": [ … ], "dedupeKey": "…", "channels": [] } }

with two headers: X-Byggradarn-Delivery — the notification's id, what to deduplicate on — and X-Byggradarn-Signature: sha256=<hex>, an HMAC-SHA256 of the exact body bytes with the secret. Verify before parsing; answer any 2xx quickly. Anything else — a 4xx, a 5xx, no answer within ten seconds — is retried on the outbox schedule (every thirty seconds, oldest first) and given up on after five attempts, with the last answer kept on the row. A receiver that is slow may see the same delivery twice; the id is what makes that harmless. The operator sees every failed try, with your answer, on the console; a backend that has refused every delivery for an hour is marked Stoppad there, so a wrong secret or a dead endpoint is noticed without anybody reading a log.

A row is made for the webhook only when the person's settings allow the webhook channel for the app, so a person can switch an app's push off the same way they switch its mail off.

Ingest — ingest

The only path an event enters by. An event is something that happened to a project at a time: a document that arrived, a notice that was published, a row in a plan. It opens a case or joins one, records values, detects changes against the previous values, and notifies subscribers. The platform decides which case by the identity keys in the catalogue; the caller never names a case.

POST /api/ingest

{ "externalId": "my-app:project:1517",
  "sourceType": "API",
  "origin": "https://example.se/plan/2026",
  "subject": "Investeringsplan 2027–2029",
  "occurredAt": "2026-09-01T00:00:00Z",
  "bodyText": null,
  "documentTitle": "Investeringsplan 2027–2029",
  "documentType": "investeringsplan",
  "documents": [],
  "datapoints": [
    { "key": "kommun", "value": "Uppsala", "confidence": 1.0, "evidence": null, "sourceFilename": null },
    { "key": "projektnummer", "value": "1517", "confidence": 1.0 },
    { "key": "titel", "value": "Gottsundaskolan, ombyggnad", "confidence": 1.0 },
    { "key": "budget", "value": "120000000", "confidence": 1.0, "evidence": "rad 14" },
    { "key": "planerad_byggstart", "value": "2027", "confidence": 0.8 }
  ],
  "agentName": "my-app-mapper",
  "warnings": [] }
  • externalId is required and, with the key's sourceKey, is the idempotency key. Post the same (sourceKey, externalId) twice and the second answers duplicate: true and changes nothing. Choose it so that the same thing has the same id and a new version has a new one.
  • sourceType is API for an app or connector, EMAIL for a message, PORTAL for a document source the poller read — a meeting portal — and MANUAL for a person.
  • occurredAt is upstream time — when the thing happened where you read it — not now. The platform stamps its own ingestedAt, and the gap between them is how detection latency is measured per source.
  • datapoints are { key, value, confidence, evidence, sourceFilename }. value is always a string; the platform normalises by the catalogue's valueType. confidence is 0–1. A key the catalogue does not carry is dropped and named in the response's warnings.
  • An identity datapoint (with its scope, e.g. kommun + projektnummer) is what places the event on a case: the case key becomes projektnummer=1517@uppsala, and a later event carrying the same identity joins it. Without one the event gets a case of its own, keyed <sourceKey>:<externalId>, and a warning says so — it can never join another event's case.
  • documents describe files the platform can already reach by storagePath; an app that has no files sends []. { filename, contentType, sizeBytes, sha256, storagePath, readStatus, readNote }.
  • bodyText, inReplyTo, references, providerThreadId, toAddresses, ccAddresses, reply are for messages and may be omitted.

Many projects in one document. A plan with forty rows is one event with records instead of datapoints: each record is { "label": "row 14", "datapoints": [ … ], "origin": "TABLE_ROW" } and is placed on its own case. A record with no usable identity is skipped and named in the warnings; the rest are placed. origin is TABLE_ROW or DESCRIBED (a project the text names but no row lists).

Answer, 200:

{ "eventId": 300, "caseId": 41, "caseKey": "projektnummer=1517@uppsala", "caseCreated": false, "duplicate": false,
  "documentsStored": 0, "valuesRecorded": 5, "changesDetected": 1, "notificationsQueued": 2,
  "recordsReceived": 1, "recordsPlaced": 1, "recordsSkipped": 0 }

For a multi-record event caseId and caseKey are null and the records* counts say what happened.

A customer app is a source. Post the customer's own project list here rather than keeping it in the app. Under a token, the source the events are filed under is app:<azp>@<account>, so two customers of one app never collide on externalId, and every case created is the account's to read. The keys a project list carries, all in the catalogue: kommun and projektnummer (the identity), titel, beskrivning, forvaltning, projektagare, budget, planerad_byggstart, planerat_fardigstallande, internationell_partner (ja/nej), finansieringsstatus (a vocabulary: idé, under bedömning, söker finansiering, ansökan pågår, inlämnad, beviljat, avslag, genomförs, avslutat) and tema (a vocabulary: energi, klimat, digitalisering, social omsorg, mobilitet, utbildning, hälsa, forskning — what the fundable part is about, which is a different axis from kategori, what is built).

POST /api/ingest?replay=true&sourceKey=<source> — the same endpoint, and with replay the credential must hold admin as well. Re-reads an event the platform already holds against today's catalogue. Values are rewritten and nobody is notified. An operator's tool, not a connector's.

Discovery — ingest

Which organisations publish what. A report, not a feed: it opens no case and notifies nobody.

POST /api/discovery202

{ "source": "dataportal", "ranAt": "…", "termsQueried": ["investeringsplan"], "requestsSpent": 12,
  "findings": [ { "publisherUri": "…", "organisationsnummer": "212000-3005", "name": "Uppsala kommun",
                  "datasetCount": 3, "terms": ["investeringsplan"] } ] }

Funding — funding:read

EU programmes, the calls that are open, and what was awarded. Public facts about money: nothing here belongs to an account, and any credential that can read a case can read these.

GET /api/funding/programmes — the catalogue, in display order:

[ { "programmeKey": "LIFE", "name": "LIFE", "swedishName": "LIFE", "manager": "Europeiska kommissionen",
    "euShare": "60–95 %", "finances": "Miljö, klimat, cirkulär ekonomi och naturvård. …",
    "coverage": "WATCHED", "openCalls": 38, "nextDeadline": "2026-10-02T16:00:00Z" },
  { "programmeKey": "INTERREG", "name": "Interreg", "swedishName": "Interreg", "manager": "…",
    "euShare": "…", "finances": "…", "coverage": "PUBLISHED_NATIONALLY", "openCalls": 0,
    "nextDeadline": null } ]
  • coverage is load-bearing. WATCHED means the platform reads this programme's calls — from the Commission's portal, or from the managing authority's own pages for ESF+, Regionalfonden and JTF (sources esf and tillvaxtverket, ADR 0043). PUBLISHED_NATIONALLY means nothing reads it yet — Interreg's secretariats, Jordbruksverket's funds — and openCalls: 0 is then a fact about our coverage, not about the world. Show "utlysningar publiceras nationellt", never a zero.
  • A national call's budget is prose. The portal's calls carry budget in euro; a call from ESF-rådet or Tillväxtverket says Budget 150000000 kr in its description and leaves the figure null, so the two are never summed.
  • euShare is text, as the programme publishes it. Never a number the platform computed, and never a figure for a project: EU money funds a component of a project, not the building, and any kronor estimate per project would be a fabrication.

GET /api/funding/calls?programme=&status=&deadlineBefore=&page=&size= — open and forthcoming calls, nearest deadline first, calls with no deadline last:

{ "identifier": "LIFE-2026-SAP-ENV-GOV", "title": "Environment governance", "programmeKey": "LIFE",
  "opensAt": "2026-04-21T00:00:00Z", "deadlineAt": "2026-09-22T00:00:00Z",
  "url": "https://ec.europa.eu/…", "source": "eu-funding", "observedAt": "2026-09-19T03:00:12Z",
  "status": "OPEN", "callIdentifier": "LIFE-2026-SAP-ENV", "callTitle": "Circular Economy and Quality of Life …",
  "description": "Expected Impact:\nApplicants are expected to …", "conditions": "Conditions\n1. Admissibility conditions …",
  "budget": 6500000.00, "minContribution": null, "maxContribution": null,
  "deadlineModel": "single-stage", "typesOfAction": "LIFE Project Grants", "documents": [] }
  • status is OPEN or FORTHCOMING; filter with status=. A kommun that must apply before investeringsbeslut needs the forthcoming set more than the open one.
  • description and conditions are the portal's prose as text — expected impact, scope, admissibility, eligibility, financial conditions. Prose stays prose: what is eligible is read out of it by a person, or by the model tier when it exists, never by a regular expression.
  • budget is the topic's indicative budget, all years summed; minContribution and maxContribution are the grant range only where the portal states one — a null is unstated, not zero, and most LIFE topics state none.
  • programme is a programmeKey; deadlineBefore an instant. A call the portal stops returning has closed and is gone from here — observedAt is when it was last seen; the history route below keeps it.

GET /api/funding/calls/history?from=&until=&programme=&page=&size= — every call the platform has ever seen that was open at some point in the window, closed ones included, oldest first:

{ "identifier": "LIFE-2025-SAP-ENV-GOV", "title": "…", "programmeKey": "LIFE", "opensAt": "…",
  "deadlineAt": "2025-09-23T16:00:00Z", "url": "…", "source": "eu-funding",
  "firstSeenAt": "2025-04-24T03:00:12Z", "lastSeenAt": "2025-09-23T03:00:09Z", "closedAt": "2025-09-24T03:00:11Z" }

closedAt is when a run stopped naming it — not the deadline, which the portal is free to ignore in both directions — and null while it is still open. This is for "what was open when this project was being planned", which is how a project already built is matched backwards. It is a separate route on purpose: the open list above never shows a passed deadline. History starts the day the platform first saw a call; from after until is 400.

GET /api/funding/calls/{identifier} — one call with its documents filled, or 404:

"documents": [ { "name": "call document", "url": "https://ec.europa.eu/info/funding-tenders/opportunities/docs/2021-2027/life/wp-call/2026/call-fiche_life-2026-sap-env_en.pdf" },
               { "name": "Application Form", "url": "https://…/af_life-pjg_en.pdf" } ]

The documents are what the call's text links to — the call fiche, the templates, the guides — by name and URL as the portal gives them. There is no date on a link, so "behöver uppdateras" is not a thing this can say; the bytes are not served here yet.

GET /api/funding/calls/{identifier}/matches?size=cases:read. The reverse: which of the projects the caller may see fit this call, best first, in the shape above. What a bevakning screen is made of: calls by deadline, and under each the portfolio's projects that fit.

GET /api/funding/awards?query=&programme=&page=&size= — projects that were granted EU money, newest start first. query matches the title or the beneficiary, case-insensitively; programme is a catalogue key and keeps the awards whose register names it.

{ "externalId": "Q7421387", "source": "kohesio", "title": "Fossilfritt 2030", "beneficiary": "Uppsala kommun",
  "euAmount": 1900000.00, "totalAmount": 4600000.00, "startsOn": "2023-04-03", "endsOn": "2026-07-01",
  "url": "https://kohesio.ec.europa.eu/…", "observedAt": "…", "programmeKey": null, "role": null }
{ "externalId": "101069359:912743326", "source": "cordis", "title": "Full spectrum SOLar Direct Air Capture",
  "beneficiary": "UPPSALA UNIVERSITET", "euAmount": 745000.00, "totalAmount": 745000.00,
  "startsOn": "2022-09-01", "endsOn": "2025-08-31", "url": "https://cordis.europa.eu/project/id/101069359",
  "observedAt": "…", "programmeKey": "HORIZON", "role": "partner", "currency": "EUR" }
{ "externalId": "NV-05931-15", "source": "klimatklivet", "title": "Omhändertagande av deponigas Kikås avfallsanläggning",
  "beneficiary": "Mölndals stad", "euAmount": null, "totalAmount": 2000000.00, "startsOn": "2015-11-30",
  "endsOn": null, "url": "https://www.naturvardsverket.se/…", "observedAt": "…",
  "programmeKey": "KLIMATKLIVET", "role": null, "currency": "SEK" }

Amounts are in currency, and an app never adds across it. The Commission's registers pay euro; a national one pays kronor. Five registers: Kohesio (kohesio) names one beneficiary per funded operation and no programme; CORDIS (cordis) names every Swedish participant in a Horizon project with its rolecoordinator or partner, the POC's kommunens roll — and the euAmount is that participant's own share; LIFE (life) and Erasmus+ (erasmus) name the Swedish coordinator, the EU grant and the whole budget where published; Klimatklivet (klimatklivet) is Naturvårdsverket's own investment grant, in kronor as totalAmount with no euAmount, because no EU money is in it. programmeKey and role are null where the register does not say.

Confirmations only. There is no parameter, filter or count anywhere in this API by which to ask which organisations did not receive money, and a test keeps it that way. An absence is a fact about our coverage — an application refused, filed under a partner's name, or never made all look the same from here — and the only version of it that is never wrong is saying nothing. Show what was awarded; never a blank beside it.

GET /api/funding/portfoliocases:read. The caller's account's projects, summed. A credential with no account — a key, a machine client — is 403; it has no portfolio to show.

{ "account": "uppsala", "projects": 6, "withBudget": 6, "totalBudget": 199000000,
  "byStatus": [ { "status": "ide", "projects": 4 }, { "status": "under_bedomning", "projects": 2 } ] }

No EU figure. What the EU would pay for a project is not derivable from its budget, and a portfolio "finansieringspotential" would be that fabrication summed. A project with no finansieringsstatus is counted under the empty status.

GET /api/funding/coverage — what the platform holds and how fresh it is, for a screen that says so honestly:

{ "programmesWatched": 14, "programmesPublishedNationally": 1, "openCalls": 595, "awards": 3568,
  "callSources": [ { "source": "eu-funding", "rows": 595, "lastObservedAt": "2026-09-18T03:00:12Z" } ],
  "awardSources": [ { "source": "kohesio", "rows": 3568, "lastObservedAt": "2026-09-14T04:10:41Z" } ] }

GET /api/funding/awards/stats?programme= — the aggregate:

{ "projects": 3568, "euAmount": 4120000000.00, "withTotal": 3102, "averageTotal": 9100000.00,
  "byYear": [ { "year": 2015, "projects": 210, "euAmount": 180000000.00 }, … ], "currency": "EUR" }

averageTotal is over the withTotal awards that published a whole budget, and null when none did; byYear counts by start date and leaves out awards without one. programme=HORIZON counts only the awards whose register named that programme — what a kommun asking vad brukar beviljas inom Horisont is shown. The sums are in one currency and never mixed: euro across every programme, and a programme's own — programme=KLIMATKLIVET answers kronor — where its register pays something else; projects counts every award asked about either way.

Writes — ingest

Delivered by a connector. Each report is the whole set for its source; what a run stops naming is treated as closed. An empty calls or awards is refused, because a failed run and an empty world look the same from here.

POST /api/funding/calls202

{ "source": "eu-funding", "ranAt": "…",
  "calls": [ { "identifier": "LIFE-2026-SAP-ENV-GOV", "title": "…", "programmeKey": "LIFE",
               "opensAt": "…", "deadlineAt": "…", "url": "https://…" } ] }

POST /api/funding/awards202

{ "source": "kohesio", "ranAt": "…",
  "awards": [ { "externalId": "Q7421387", "title": "…", "beneficiary": "Uppsala kommun",
                "euAmount": 1800000.00, "totalAmount": 4600000.00, "startsOn": "2023-04-03",
                "endsOn": "2026-07-01", "url": "https://…", "programmeKey": null, "role": null,
                "currency": "EUR" } ] }

currency is EUR when left out.

Runs — runs:read

What every source's runs say about it. A connector that is failing says so by posting a run with a failure rather than by going quiet, and this is how an app tells data that is current from data that is merely present — and shows "senaste datasynk" from a fact.

GET /api/runs?source= — every source that has ever reported, alphabetically:

[ { "sourceKey": "ted", "state": "FAILING",
    "lastRun":     { "startedAt": "…", "finishedAt": "…", "posted": 0, "duplicates": 0, "pages": 0, "failure": "502 from ted.europa.eu" },
    "lastSuccess": { "startedAt": "…", "finishedAt": "…", "posted": 40, "duplicates": 12, "pages": 3, "failure": null },
    "lastFailure": { "startedAt": "…", "…": "…" } } ]

state is from the latest run alone: WORKING if it completed within two days, STALE if it completed but longer ago than that — green last Tuesday is not evidence of anything today — FAILING if it did not complete. lastSuccess is the freshness to show; it is null for a source that has never completed a run. A source that has never reported is absent, not "never run": from here there is no way to know it exists.

Writes — ingest

A connector saying what its own run did. The source is the key's.

POST /api/runs202

{ "startedAt": "…", "finishedAt": "…", "posted": 40, "duplicates": 12, "pages": 3, "failure": null }

failure is a message when the run did not complete; posted and duplicates then describe what it managed before that.

GET /api/runs/control — what a person on the platform wants this connector to do before its next run, decided on the source's page under Integrationer (docs/decisions/0048). The source is the key's, so a connector can only ask about itself; the runner asks before every run and honours the answer, and a connector that cannot reach the platform runs as before.

{ "paused": false, "runRequested": true }

runRequested is cleared when the run it asked for is reported.

Outbound — ingest

A letter of ours that left the mailbox, read back from the sent folder and filed beside the ones the platform sent itself. Records; sends nothing.

POST /api/outbound

{ "messageId": "<…@mail.gmail.com>", "inReplyTo": null, "references": null, "providerThreadId": "…",
  "from": "…", "toAddresses": ["registrator@uppsala.se"], "ccAddresses": [], "subject": "…",
  "bodyText": "…", "sentAt": "…" }
→ 200 { "letterId": 88, "reference": "BR-2026-0142", "threadKey": "…", "outcome": "RECORDED" }

outcome is RECORDED, MATCHED_DRAFT (it was a draft the platform had prepared, now closed) or ALREADY_RECORDED.

Tasks — tasks

Work the platform wants a model to do, handed out with a lease. A worker in any language implements these two calls.

POST /api/tasks/claim

{ "worker": "worker-1", "kinds": ["COMPOSE", "IMPROVE"] }
→ 200 { "id": 512, "kind": "COMPOSE", "lease": "…", "leaseExpiresAt": "…",
        "prompt": { "system": "…", "user": "…" },
        "composition": { …CompositionRequest }, "improvement": null, "mirrorDraft": null }
→ 204 when nothing is waiting

prompt is the whole prompt, rendered by the platform; a thin worker passes the two strings to its model. The structured request is beside it for a worker that knows better. Kinds: COMPOSE (write a letter), IMPROVE (improve the guidance letters are written from), MIRROR_DRAFT (declared, not yet handed out).

POST /api/tasks/{id}/result

{ "lease": "…", "composition": { …CompositionResult }, "improvement": null, "mirrorDraft": null,
  "failure": null }
→ 200

Exactly one of composition, improvement, mirrorDraft or failure is set. A result under an expired lease, or for a task already answered, is 409. A failure returns the task to the queue; after three it is a failure a person can see. The record shapes are the WorkerTask*, CompositionRequest/CompositionResult and ImprovementRequest/ImprovementResult records in the byggradarn-domain module.

Requests — admin

Sending a begäran to a kommun, and the address book it goes to. A person is the send control: nothing here is reachable by an app key.

GET /api/requests — letters sent: { id, templateKey, recipient, email, reference, status, sentAt, answeredAt, lastError }.

GET /api/requests/templates[ { "templateKey": "…", "displayName": "…", "cadenceDays": 90 } ].

GET /api/requests/recipients[ { "id": 3, "name": "…", "email": "…", "kommun": "Uppsala" } ].

POST /api/requests/recipients{ "name": string, "email": string, "kommun": string | null }.

POST /api/requests/send{ "templateKey": string, "recipientId": int } → the letter as sent.

Administration — admin

POST /api/admin/api-keys/connectors{ "name": string, "sourceKey": string, "expiresAt": instant | null }201, an INGEST key with secret, once.

POST /api/admin/api-keys/workers{ "name": string, "expiresAt": instant | null }201, a WORKER key with secret, once.

DELETE /api/admin/api-keys/{id}204.

A worked example: an app that keeps a kommun's project list

The smallest working client — a page that signs a person in with PKCE, calls GET /api/me and shows the account and its apps from the token — is examples/reference-client/index.html in the platform repository: one file, no framework, the whole flow above in a hundred lines of plain JavaScript. Start there.

  1. An administrator registers the app on /admin/appar and ticks it on the customer's account; or, for a connector, issues it an INGEST key with sourceKey: "my-app".
  2. The app reads GET /api/definitions once and maps its columns to keys: its Projektnummer column is projektnummer, its Budget is budget, and every row carries kommun.
  3. For each project it posts POST /api/ingest with externalId = its own stable id for the project and occurredAt = the date of the plan. The first post creates the case; a later post with a changed budget records the change, and everybody subscribed hears about it.
  4. The app reads its projects back from GET /api/cases and each one's story from GET /api/cases/{id}/changes.
  5. A person in the kommun who wants the e-mail creates a subscription with predicates: [{ "datapointKey": "kommun", "operator": "EQUALS", "values": ["Uppsala"] }]. Made under the app's token, it belongs to the app, and so does every notification it produces.
  6. The app's bell reads GET /api/notifications/unread-count and its list GET /api/notifications; each row's target becomes a route of the app's own, its actions buttons, and a press is reported with POST /api/notifications/{id}/acted.
  7. When the app itself has something to say — an application deadline it tracks — it posts POST /api/notifications to { "account": "uppsala" } with a dedupeKey, and the platform applies each person's settings before it makes a row.
  8. Its settings screen is a form over GET/PUT /api/me/notifications/settings; its backend, if it has one, registers PUT /api/apps/{key}/webhook and pushes from there.

The reference client does 6 to 8 as well, and reference-client.spec.mjs in the platform's browser suite runs the whole protocol once, from another origin.

What is coming

Named here so an app is not built against something that is about to change. None of these is available yet.

  • The platform's own sign-in through the providerAuthentication, What is coming.
  • More of funding: the documents' bytes, and a model reading the call text to add evidence to the matches.
  • Documents by API, so an app can hand the platform a file rather than a path, and read a case's document bytes back.

Where the reasoning is

  • Why ingest is the only write path, and what it does with a document: docs/ingestion.md in the platform repository.
  • What each datapoint means: docs/datapoints.md.
  • Why a call is not an event, why the funding view never says who did not apply, and why the agents run outside the platform: docs/decisions/0031, 0030, 0037.