ERP integration
Connect a property-management or billing ERP to messpunkt.io: register a client, let the landlord consent once, then read monthly consumption for years without further user interaction. This page covers the whole lifecycle. The Quickstart is the five-minute version.
auth.messpunkt.io, api.messpunkt.io) has been running against the landlords' real data since 30 September 2026. ERP clients are registered on request (step 1), and API access is enabled per customer organisation (step 2). The sandbox with synthetic data stays available. Everything on this page is marked where sandbox and production differ. The API is read-only today; what is planned for writing occupancies is on the roadmap.
How it fits together
| Sandbox | Production | |
|---|---|---|
| Authorization server | https://auth.sandbox.messpunkt.io | https://auth.messpunkt.io |
| API base URL | https://api.sandbox.messpunkt.io/v1 | https://api.messpunkt.io/v1 |
resource (RFC 8707) | https://api.sandbox.messpunkt.io | https://api.messpunkt.io |
| Client | public demo client sandbox-demo-client (PKCE only) | your pre-registered confidential client |
| Landlord sign-in | demo landlord, already signed in | the landlord's messpunkt.io portal account |
| Data | synthetic, static until 2026-12-31 | the landlord's real data |
The ERP uses the same authorization server and the same consent screen as AI assistants. Tokens are issued for one resource: a token for the REST API is not accepted by the MCP server and vice versa.
1Register your client
ERP clients are registered by messpunkt.io. Write to kontakt@messpunkt.io with:
- Display name: shown to landlords on the consent screen and in their portal under Verbundene Anwendungen.
- Redirect URIs: matched exactly. Use
https; loopback URIs (http://localhost,http://127.0.0.1,http://[::1]) are matched with any port for desktop installations (RFC 8252). No fragments. - Scopes you need:
read:units,read:devices,read:readings. - Client authentication:
private_key_jwt(preferred; send us thehttpsURL of your JWKS) orclient_secret_basic. A client secret is shown once, in the registration response, and stored by us only as a hash. Keep it in your secret store; if it is lost, we rotate it and the old one stops working. - DPoP: optional. On request we mark your client as DPoP-bound; the token endpoint then refuses to issue tokens without a proof.
You receive a client_id of the form erp_ followed by 32 hex characters.
private_key_jwt or client_secret_basic there. The rules below are what the authorization server enforces; its discovery document lists them in token_endpoint_auth_methods_supported (none, client_secret_basic, private_key_jwt) and token_endpoint_auth_signing_alg_values_supported.
2Make sure the landlord can consent
API access is part of the landlord's contract with messpunkt.io and is enabled per customer organisation. If it is not enabled, the consent screen shows a notice (“Verbindung noch nicht freigeschaltet”) instead of a form, and no code is issued. Ask the landlord to contact messpunkt.io. If the landlord later loses API access, all their connections are revoked. Re-enabling access does not restore them: the landlord connects again.
3Run the authorization flow (sandbox)
Every command below runs against the sandbox. Read the endpoints from the discovery document instead of hard-coding them:
curl -s https://auth.sandbox.messpunkt.io/.well-known/oauth-authorization-server | python3 -m json.tool
Look for issuer, authorization_endpoint, token_endpoint, revocation_endpoint, code_challenge_methods_supported: ["S256"] and authorization_response_iss_parameter_supported: true.
3a · PKCE and state
VERIFIER=$(openssl rand -base64 48 | tr -d '=+/' | head -c 64)
CHALLENGE=$(printf "%s" "$VERIFIER" | openssl dgst -binary -sha256 \
| openssl base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
STATE=$(openssl rand -hex 16)
PKCE with S256 is mandatory for every client, confidential or not. Keep STATE bound to the landlord's browser session in your ERP.
3b · Send the landlord to the consent screen
echo "https://auth.sandbox.messpunkt.io/oauth/authorize?response_type=code\
&client_id=sandbox-demo-client\
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback\
&scope=read%3Aunits%20read%3Adevices%20read%3Areadings\
&state=$STATE\
&code_challenge=$CHALLENGE&code_challenge_method=S256\
&resource=https%3A%2F%2Fapi.sandbox.messpunkt.io"
resource, in the sandbox and in production. For an ERP client a missing resource falls back to the REST API; an unknown one is invalid_target.Open the printed URL in a browser. In production the landlord signs in first. The consent screen then shows your display name and what will be shared, and lets the landlord pick the customer organisation (if they manage several) and either all Properties, including future ones (pre-selected) or a selection. In the sandbox the demo landlord is already signed in and all Properties is pre-selected: approve, or switch to only selected and tick Properties.
The browser is redirected to http://localhost:3000/callback?code=…&iss=…&state=…. Nothing listens there in this walkthrough, so copy the values from the address bar:
CODE=... # from the address bar
ISS=... # URL-decoded
RETURNED_STATE=...
3c · Check iss and state before you redeem
[ "$ISS" = "https://auth.sandbox.messpunkt.io" ] || echo "STOP: unexpected issuer"
[ "$RETURNED_STATE" = "$STATE" ] || echo "STOP: state mismatch"
iss (RFC 9207) must equal the issuer of the discovery document you started with. This protects you against mix-up attacks if your ERP talks to more than one authorization server. Errors come back the same way (error, error_description, iss, state). access_denied means that the landlord declined.
3d · Exchange the code
curl -s -X POST https://auth.sandbox.messpunkt.io/oauth/token \
-d "grant_type=authorization_code" \
-d "code=$CODE" \
-d "client_id=sandbox-demo-client" \
-d "redirect_uri=http://localhost:3000/callback" \
-d "code_verifier=$VERIFIER" \
-d "resource=https://api.sandbox.messpunkt.io" > token.json
TOKEN=$(python3 -c "import json; print(json.load(open('token.json'))['access_token'])")
REFRESH_TOKEN=$(python3 -c "import json; print(json.load(open('token.json'))['refresh_token'])")
The code is single-use and short-lived. Redeeming it twice revokes the connection. The response carries access_token (expires_in: 3600), refresh_token, scope and token_type (Bearer, or DPoP if you sent a proof).
3e · Authenticate a confidential client (production)
A registered ERP client authenticates every request to /oauth/token and /oauth/revoke with exactly one method. A request that combines methods is refused with 400 invalid_request; a failed authentication with 401 invalid_client. A failed authentication never uses up the authorization code.
private_key_jwt (RFC 7523). Send these form fields instead of a secret:
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
client_assertion=<signed JWT>
- Header:
algisRS256,PS256orES256.kidnames a key in your registered JWKS. Do not includejwk,jku,x5u,x5corcrit. - Claims:
iss=sub= yourclient_id.audis the token endpoint URL (https://auth.messpunkt.io/oauth/token); the issuer identifier is accepted too.iatandexpare required, at most 5 minutes apart.jtiis required and must be new for every request, including a revocation right after a token request. - JWKS: served over
httpson port 443 from a public DNS name (no IP literal, no redirect), JSON, at most 64 KB. Keys are RSA with at least 2048 bits or EC P-256. To rotate, publish the new key under a newkidnext to the old one; the server reloads your JWKS at most every 30 seconds. client_idin the form is optional; if you send it, it must equaliss.
client_secret_basic. Send the secret only in the Authorization header: Basic base64(urlencode(client_id) ":" urlencode(client_secret)), for example curl -u "$CLIENT_ID:$CLIENT_SECRET". A secret in the form body (client_secret_post) is not supported and is refused.
The public demo client of the sandbox sends only client_id. A public client that sends a secret or an assertion is refused.
4Read data
curl -s -H "Authorization: Bearer $TOKEN" https://api.sandbox.messpunkt.io/v1/whoami | python3 -m json.tool
curl -s -H "Authorization: Bearer $TOKEN" https://api.sandbox.messpunkt.io/v1/properties | python3 -m json.tool
# for each Property: the complete list of its measuring points, with and without a unit
PROPERTY_ID=$(curl -s -H "Authorization: Bearer $TOKEN" https://api.sandbox.messpunkt.io/v1/properties \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['id'])")
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.sandbox.messpunkt.io/v1/properties/$PROPERTY_ID/measuring-points?limit=100" | python3 -m json.tool
UNIT=$(curl -s -H "Authorization: Bearer $TOKEN" https://api.sandbox.messpunkt.io/v1/usage-units \
| python3 -c "import json,sys; print(json.load(sys.stdin)['data'][0]['id'])")
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.sandbox.messpunkt.io/v1/usage-units/$UNIT/consumption?from=2025-01-01&to=2025-12-31" \
| python3 -m json.tool
GET /v1/properties/{id}/measuring-points is the complete list of a Property's measuring points. /v1/usage-units alone misses plant-room and main meters, which belong to no unit (see below).
| Endpoint | Scope |
|---|---|
GET /v1/whoami | none beyond a valid token |
GET /v1/properties, GET /v1/properties/{id} | read:units |
GET /v1/usage-units, GET /v1/usage-units/{id} | read:units |
GET /v1/usage-units/{id}/consumption | read:readings |
GET /v1/properties/{id}/measuring-points (paginated; every measuring point of the Property, with and without a unit) | read:devices |
GET /v1/measuring-points/{id} | read:devices |
GET /v1/measuring-points/{id}/readings | read:readings |
The monthly rule, in short
- Readings are month-end values (
atis a date).from/toare widened to whole months, andtois capped at the last completed month (check the echoed value). There is no parameter for finer values. - Measuring point (
/v1/measuring-points/{id}/readings): billperiod_consumption.billable_valuesummed over its segments. - Usage unit (
/v1/usage-units/{id}/consumption): billperiod_consumption.weighted_billable_valuesummed over the segments. It isbillable_value×unit_share: equal tobillable_valuefor a measuring point the unit does not share (unit_share: 1), the unit's share of a shared meter otherwise. Never sumbillable_valueper unit: that bills a shared meter in full to every unit. - Sum rule: the sum of
weighted_billable_valueover all units of a Sector equals the Sector's consumption (up to rounding), so a shared measuring point is never counted twice. Details: Shared Sectors under Canonical model in the Reference. - Do not bill
last − firstreading yourself: that misses the baseline beforefrom, the start value of a replacement device and the factor. baseline_originon each segment says wherebaseline_valuecomes from:recorded,assumed_zero,first_reading,portal_rule,month_end_before_range,first_known_in_rangeornone(tolerate unknown values; the field is optional).assumed_zero: the start value of a replacement heat cost allocator was not recorded and is assumed to be 0 — the consumption of that segment rests on this assumption. Show it to the landlord instead of billing it silently. Example:hca_replacement_assumed_zeroin the Reference under/v1/measuring-points/{id}/readings; details in Meter readings and consumption.- Never bill a value with status
calibration_expired.calculatedvalues are interpolated (is_synthetic: true). water_outdooris a separate metric and not part of residents' water. Each metric has its own unit.- Difference meters (
is_difference_meter: true) have no device and no value in V1. is_system_linked: false: the measuring point has no active system assignment (heating or water system). You get its values, but the landlord portal shows 0 for it — clarify with the landlord before billing. An absent field meanstrue.- Without a unit (no allocation,
usage_unit_id: null, below) is not the same as not system-linked (is_system_linked: false). A plant-room meter can be system-linked and still belong to no unit; an apartment meter can belong to a unit and still lack a system link.
Full contract: Data resolution in the Reference.
Meters that belong to no apartment (plant room, main meters)
Not every meter belongs to a usage unit: a total heat meter, a boiler room or a main water meter sits in an area that is allocated to no unit. These measuring points are part of the API. Their usage_unit_id is null and allocations is empty; property_id always names the property. List them with GET /v1/properties/{id}/measuring-points (scope read:devices, paginated) — the list contains every measuring point of the property, with and without a unit — and read their values with GET /v1/measuring-points/{id}/readings. They never appear under a unit and are not part of GET /v1/usage-units/{id}/consumption, and they are never spread over the units for you: the total meter is the property's own figure, and how it is split among apartments is your billing rule. Handle usage_unit_id: null in your client — it is a breaking change of rc.3, which is not published yet.
curl -s -H "Authorization: Bearer $TOKEN" \
"https://api.sandbox.messpunkt.io/v1/properties/$PROPERTY_ID/measuring-points?limit=100" \
| python3 -m json.tool
{
"data": [
{
"id": "e7a1c3d2-58b4-4f0e-9c6a-3b2d1f0a8e57",
"usage_unit_id": null,
"property_id": "718e092d-8626-4975-b800-252a7746ad08",
"metric": "heat",
"unit": "kWh",
"allocations": [],
"localization": "Technikraum Gesamtwärmezähler",
"is_difference_meter": false,
…
},
…
],
"next_cursor": null
}
Details: Shared Sectors and Sectors without a share under Canonical model in the Reference.
Property scope
Call /v1/whoami after every token issue and refresh and reconcile your local Property list with property_scope ("all" or a list of UUIDs). A resource outside the landlord's selection returns 404, never 403. Treat a 404 on an ID you knew as “access withdrawn”, not as a reason to retry. When the landlord wants a different selection, they disconnect and consent again.
5Refresh, rotation and replay
curl -s -X POST https://auth.sandbox.messpunkt.io/oauth/token \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN" \
-d "client_id=sandbox-demo-client" > token.json
- Access tokens live one hour. Refresh tokens expire after 90 days without use. There is no absolute cap, so a connection lasts as long as you keep rotating.
- Every refresh returns a new refresh token. Persist it before you use the new access token.
- Presenting an already-rotated refresh token is treated as theft: the whole token family and the connection are revoked, including your current tokens. Two concurrent refreshes with the same token have the same effect. Serialise refreshes per connection.
- Any
invalid_granton refresh means “start a new authorization flow”. Show the landlord a reconnect button. - You may narrow
scopeon refresh, never widen it (invalid_scope).
6Disconnect
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://auth.sandbox.messpunkt.io/oauth/revoke \
-d "token=$REFRESH_TOKEN" \
-d "client_id=sandbox-demo-client"
/oauth/revoke since 30 September 2026, production from the start.Revocation (RFC 7009) accepts an access or a refresh token and ends the whole connection. The answer is 200 whether or not the token was known. Authenticate the request exactly as at the token endpoint; an unknown, disabled or unauthenticated client gets 401 invalid_client. The landlord can also disconnect your application in the portal (Einstellungen › Verbundene Anwendungen); your tokens and refresh token then stop working within 60 seconds, because the connection is checked on every request.
DPoP (optional)
DPoP (RFC 9449) is optional, but when you use it the server checks it fully: htm, htu, iat (±60 s), jti replay, ath and the key thumbprint. Send a proof to /oauth/token and the access token is bound to your key (token_type: DPoP). Every API request must then use Authorization: DPoP <token> plus a fresh DPoP proof, and the same token sent as Bearer is rejected. The htu is the public URL you called, without query. The server does not issue DPoP nonces; do not put a nonce claim in your proofs. Code in C#, Node, Python and curl: DPoP guide.
Errors
API endpoints (/v1/*) answer errors as application/problem+json. type is https://developer.messpunkt.io/errors/#<code> — the same in sandbox and production, and a link to the code's explanation. Branch on the fragment after #:
| Status | What happened | What to do |
|---|---|---|
400 invalid-request | bad parameter; detail names it | fix the request |
401 invalid-token | token missing, expired or wrong audience. WWW-Authenticate carries error="invalid_token", or error="invalid_dpop_proof" for a bad proof | refresh, or fix the proof |
403 insufficient-scope | the token lacks the scope; WWW-Authenticate names it in scope="…" | ask the landlord to reconnect with that scope |
404 resource-not-found | does not exist or is outside the Property scope (also: an ID that is not a UUID) | do not retry; re-check /v1/whoami |
429 too-many-requests | rate limit | wait Retry-After seconds |
500 / 503 | server or dependency problem; no detail | retry with exponential backoff |
OAuth endpoints (/oauth/*) answer in the OAuth format (error, error_description): invalid_request, invalid_client (401; for client_secret_basic with WWW-Authenticate: Basic), invalid_grant (code or refresh token invalid, reused, revoked or inactive), invalid_target (unknown resource), invalid_scope, invalid_dpop_proof, unsupported_grant_type. On /oauth/authorize, an unknown client or unregistered redirect_uri is never redirected: a browser gets an error page for the landlord, a caller that does not accept text/html gets 400 with error (invalid_client, invalid_redirect_uri). Full table with client actions: Error handling.
Rate limits
| Endpoint | Limit | Counted per |
|---|---|---|
/v1/* | 300 requests/min | connection (grant, the token's sid), after the token is validated. Requests whose token fails validation are counted per caller address. |
/oauth/token | 60 requests/min | client_id, after successful client authentication; in addition 60/min per caller address |
/oauth/revoke | 60 requests/min | client_id, as at the token endpoint; in addition 60/min per caller address |
/oauth/authorize | 60 requests/min | caller address (the landlord's browser) |
The limit on /v1/* is per connection, not per client_id or address, because one ERP system serves many landlords from one address, and addresses behind proxies are unreliable. Above a limit you get 429 with Retry-After, as application/problem+json with type …/errors/#too-many-requests. Production and sandbox use the same numbers. They are counted in memory per server instance, so the effective limit can be higher than the number above, never lower.
Sandbox notes
- The data is synthetic and static up to 2026-12-31. A range reaching past December 2026 is shortened, and one starting after it returns no values.
- The status
substituteddoes not occur in the sandbox. It is documented in the Reference but cannot be tried out. - Since 30 September 2026 the sandbox serves the rc.3 contract, including
resourceand/oauth/revoke(see the changelog).