messpunkt.io Partner API · ERP integration
Preview V1 release candidate 1.0.0-rc.3 — production live since 30 September 2026; access per customer organisation on request: kontakt@messpunkt.io
Integration guide

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.

Where things stand. Production (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

SandboxProduction
Authorization serverhttps://auth.sandbox.messpunkt.iohttps://auth.messpunkt.io
API base URLhttps://api.sandbox.messpunkt.io/v1https://api.messpunkt.io/v1
resource (RFC 8707)https://api.sandbox.messpunkt.iohttps://api.messpunkt.io
Clientpublic demo client sandbox-demo-client (PKCE only)your pre-registered confidential client
Landlord sign-indemo landlord, already signed inthe landlord's messpunkt.io portal account
Datasynthetic, static until 2026-12-31the 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 the https URL of your JWKS) or client_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.

Confidential clients exist only in production. The sandbox has only the public demo client, so you cannot try 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"
Always send 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: alg is RS256, PS256 or ES256. kid names a key in your registered JWKS. Do not include jwk, jku, x5u, x5c or crit.
  • Claims: iss = sub = your client_id. aud is the token endpoint URL (https://auth.messpunkt.io/oauth/token); the issuer identifier is accepted too. iat and exp are required, at most 5 minutes apart. jti is required and must be new for every request, including a revocation right after a token request.
  • JWKS: served over https on 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 new kid next to the old one; the server reloads your JWKS at most every 30 seconds.
  • client_id in the form is optional; if you send it, it must equal iss.

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).

EndpointScope
GET /v1/whoaminone 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}/consumptionread: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}/readingsread:readings

The monthly rule, in short

  • Readings are month-end values (at is a date). from/to are widened to whole months, and to is capped at the last completed month (check the echoed value). There is no parameter for finer values.
  • Measuring point (/v1/measuring-points/{id}/readings): bill period_consumption.billable_value summed over its segments.
  • Usage unit (/v1/usage-units/{id}/consumption): bill period_consumption.weighted_billable_value summed over the segments. It is billable_value × unit_share: equal to billable_value for a measuring point the unit does not share (unit_share: 1), the unit's share of a shared meter otherwise. Never sum billable_value per unit: that bills a shared meter in full to every unit.
  • Sum rule: the sum of weighted_billable_value over 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 − first reading yourself: that misses the baseline before from, the start value of a replacement device and the factor.
  • baseline_origin on each segment says where baseline_value comes from: recorded, assumed_zero, first_reading, portal_rule, month_end_before_range, first_known_in_range or none (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_zero in the Reference under /v1/measuring-points/{id}/readings; details in Meter readings and consumption.
  • Never bill a value with status calibration_expired. calculated values are interpolated (is_synthetic: true).
  • water_outdoor is 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 means true.
  • 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_grant on refresh means “start a new authorization flow”. Show the landlord a reconnect button.
  • You may narrow scope on 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"
The sandbox has /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 #:

StatusWhat happenedWhat to do
400 invalid-requestbad parameter; detail names itfix the request
401 invalid-tokentoken missing, expired or wrong audience. WWW-Authenticate carries error="invalid_token", or error="invalid_dpop_proof" for a bad proofrefresh, or fix the proof
403 insufficient-scopethe token lacks the scope; WWW-Authenticate names it in scope="…"ask the landlord to reconnect with that scope
404 resource-not-founddoes 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-requestsrate limitwait Retry-After seconds
500 / 503server or dependency problem; no detailretry 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

EndpointLimitCounted per
/v1/*300 requests/minconnection (grant, the token's sid), after the token is validated. Requests whose token fails validation are counted per caller address.
/oauth/token60 requests/minclient_id, after successful client authentication; in addition 60/min per caller address
/oauth/revoke60 requests/minclient_id, as at the token endpoint; in addition 60/min per caller address
/oauth/authorize60 requests/mincaller 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 substituted does 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 resource and /oauth/revoke (see the changelog).