Sending Signals (API v2)¶
Upcoming
Signal API v2 is not yet available in production. This page describes an upcoming release, and details can still change until it ships. If you want to prepare your integration or try v2 early, contact h2-stations@clean-hydrogen.europa.eu.
Signal API v2 splits signal transmission into one endpoint per signal category. This page is the integration reference: endpoints, the shared payload envelope, per-category payload formats with examples, validation rules, and the responses you can expect.
If you are moving an existing v1 or v1.1 integration, read the Migration Guide first. It explains what changed and in which order to proceed.
Endpoints¶
| Endpoint | Purpose |
|---|---|
POST https://api.h2-stations.eu/import/v2/signal/availability/ |
Which nozzles can currently dispense, plus whole-station state |
POST https://api.h2-stations.eu/import/v2/signal/usage/ |
Whether each nozzle is free, in use, or reserved |
POST https://api.h2-stations.eu/import/v2/signal/hydrogen-storage/ |
On-site hydrogen inventory, as kilograms or as a low-inventory flag |
POST https://api.h2-stations.eu/import/v2/signal/pricing/ |
Current hydrogen price per registered product |
GET https://api.h2-stations.eu/import/v2/active-stations/ |
The stations your token is assigned to, per signal type |
Note the trailing slash. The v1 and v1.1 endpoints are written without one, the v2 endpoints require it.
Every request needs two headers:
| Header | Value |
|---|---|
Authorization |
Bearer <your-v2-token> |
Content-Type |
application/json |
v2 uses a new token system. Tokens are created in the portal by originator managers, or issued by H2-Stations during onboarding, and a v1 token does not authenticate against v2 endpoints. See Authentication in the migration guide.
The shared envelope¶
The request body of every signal endpoint is a JSON array of transmissions. One request may carry transmissions for several stations, but at most one transmission per station. Every transmission starts from the same envelope:
| Field | Type | Required | Notes |
|---|---|---|---|
hrs |
string | yes | The station's HRS ID (e.g. ABCDE). The station must exist on the platform. |
datetime |
string | yes | When the reading was measured, ISO 8601 with UTC offset. Readings older than 5 minutes are rejected, and so are readings more than 30 seconds in the future. |
transmission_id |
string | no | Free-form identifier for your transmission run. Stored for log correlation, useful when we investigate an issue together. |
dry_run |
boolean | no | Defaults to false. When true, the transmission is validated end to end but nothing is stored or published. See Testing with dry_run. |
Each category adds its own payload section on top of the envelope: nozzles, storages, or products.
Batches are all or nothing. The whole request is validated before anything is stored. If any transmission in the array fails validation, the request returns 400 and no transmission from that request is applied, including the valid ones. This differs from v1, where valid signals were accepted even when the overall response was a 400.
Transmissions are full snapshots. Each accepted transmission replaces the previous state for that station and signal category. An entity you omit from a transmission no longer has reported state afterwards, so include every nozzle, storage, or product in every transmission.
Registered identifiers¶
v2 signals reference the station's registered layout. Nozzle, dispenser, storage, and product identifiers must exist in the portal for that station before you can send signals against them. The HRS operator registers the station layout through the portal, and H2-Stations can register it with you during onboarding.
The API never guesses: an unknown identifier is rejected with a 400 naming the identifier, and only identifiers from a layout the operator has confirmed will resolve. If a 400 names an identifier you believe is correct, the usual cause is that the operator has not confirmed that part of the layout yet.
Dispenser shorthand (availability and usage): if your telemetry is only available per dispenser, send dispenser_id instead of id in a nozzle entry. The platform fans the state out to every nozzle of that dispenser. You can mix nozzle entries and dispenser entries in one transmission as long as they do not overlap, and each entry must carry exactly one of the two fields.
Availability signal¶
Reports which nozzles can currently dispense fuel correctly, plus the whole-station state.
In addition to the envelope:
nozzles(array, required, must not be empty). Each entry carriesid(a registered nozzle identifier) ordispenser_id(a registered dispenser identifier), plusavailable(boolean, required,nullis rejected).station(object, required):maintenanceandlimitedly_available, both boolean and both required. These describe the whole station and override the per-nozzle flags downstream: a station in maintenance is treated as out of service regardless of what its nozzles report.
curl -X POST https://api.h2-stations.eu/import/v2/signal/availability/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '[
{
"hrs": "ABCDE",
"transmission_id": "run-2041",
"datetime": "2026-07-11T09:41:00+02:00",
"nozzles": [
{"id": "NOZ-01", "available": true},
{"id": "NOZ-02", "available": false},
{"dispenser_id": "DISP-02", "available": true}
],
"station": {"maintenance": false, "limitedly_available": false}
}
]'
import datetime
import requests
TOKEN = "..." # your v2 token
transmission = {
"hrs": "ABCDE",
"transmission_id": "run-2041",
"datetime": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"nozzles": [
{"id": "NOZ-01", "available": True},
{"id": "NOZ-02", "available": False},
{"dispenser_id": "DISP-02", "available": True},
],
"station": {"maintenance": False, "limitedly_available": False},
}
response = requests.post(
"https://api.h2-stations.eu/import/v2/signal/availability/",
json=[transmission],
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=10,
)
response.raise_for_status() # 204 on success
Before you copy-paste: replace the
datetimevalue with the current time. Readings older than 5 minutes are rejected.
The platform derives the published per-fuelling-option status from your nozzle-level data and the station layout. If the layout does not allow any fuelling option to be derived, the transmission is rejected with a 400 asking to check the station registration.
Usage signal¶
Reports whether each nozzle is currently free, dispensing, or reserved. There is no station object on this signal.
nozzles(array, required, must not be empty). Each entry carriesidordispenser_idplususage_status(string, required), one of:
| Value | Meaning |
|---|---|
free |
Nozzle idle, ready to serve the next vehicle |
in-use |
Nozzle currently dispensing |
reserved |
Nozzle reserved for a known incoming vehicle |
When a multi-nozzle dispenser cannot serve two vehicles at once, report the blocked sibling nozzles as in-use too, since they cannot serve a vehicle right now.
curl -X POST https://api.h2-stations.eu/import/v2/signal/usage/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '[
{
"hrs": "ABCDE",
"datetime": "2026-07-11T09:41:00+02:00",
"nozzles": [
{"id": "NOZ-01", "usage_status": "free"},
{"id": "NOZ-02", "usage_status": "in-use"},
{"id": "NOZ-03", "usage_status": "reserved"}
]
}
]'
import datetime
import requests
TOKEN = "..." # your v2 token
transmission = {
"hrs": "ABCDE",
"datetime": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"nozzles": [
{"id": "NOZ-01", "usage_status": "free"},
{"id": "NOZ-02", "usage_status": "in-use"},
{"id": "NOZ-03", "usage_status": "reserved"},
],
}
response = requests.post(
"https://api.h2-stations.eu/import/v2/signal/usage/",
json=[transmission],
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=10,
)
response.raise_for_status()
Hydrogen storage signal¶
Reports the station's on-site hydrogen inventory per storage unit, so that drivers can be warned before arriving at a station that is running low.
storages(array, required, must not be empty). Each entry carries the registered storageidplus at least one of:remaining_kg(decimal string or number, up to 8 digits with 2 decimal places, not negative): the current usable inventory. The platform applies the low-inventory threshold itself.low_h2_storage(boolean): send this if you prefer to apply the threshold on your side or your system has no numeric reading.truemeans inventory is below the threshold.
The threshold is 100 kg per storage (following the current AFIR requirements). A storage at exactly 100 kg is not low. If you send both fields and they contradict each other at that threshold, the transmission is rejected with a 400 explaining the inconsistency.
The numeric inventory is treated as confidential. Data consumers only ever see the derived low-inventory flag, never the kilogram value.
curl -X POST https://api.h2-stations.eu/import/v2/signal/hydrogen-storage/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '[
{
"hrs": "ABCDE",
"datetime": "2026-07-11T09:41:00+02:00",
"storages": [
{"id": "STOR-01", "remaining_kg": "84.00"},
{"id": "STOR-02", "low_h2_storage": false}
]
}
]'
import datetime
import requests
TOKEN = "..." # your v2 token
transmission = {
"hrs": "ABCDE",
"datetime": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"storages": [
{"id": "STOR-01", "remaining_kg": "84.00"},
{"id": "STOR-02", "low_h2_storage": False},
],
}
response = requests.post(
"https://api.h2-stations.eu/import/v2/signal/hydrogen-storage/",
json=[transmission],
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=10,
)
response.raise_for_status()
Pricing signal¶
Reports the current hydrogen price per product. Products are registered entities, like nozzles and storages: each station registers the products it sells (pressure stage, declared green-hydrogen share, the nozzles that dispense it), and the pricing signal references the product identifier. There is one price per station and product, prices are not keyed to nozzles or dispensers.
Products are registered by the operator in the portal, not through the import API. The identifiers to send are published in the products section of the export API v2 station payload. A price for an identifier the station has not registered is rejected with a validation error naming it, so read the products section to learn what you may price. If the operator removes a product later, its last price stops being published immediately, without waiting for your next transmission.
products(array, required, must not be empty). Each entry:
| Field | Type | Required | Notes |
|---|---|---|---|
id |
string | yes | A registered product identifier. Duplicates within one transmission are rejected. |
gross_price |
decimal string | yes | The VAT-inclusive retail price per kg, what a driver pays at the dispenser. Up to 8 digits with 3 decimal places, strictly greater than zero. |
net_price |
decimal string | no | The pre-VAT price per kg for professional consumers. Same format, must not exceed gross_price. |
currency |
string | no | ISO 4217 code, defaults to EUR. Accepted: EUR, CHF, GBP, DKK, SEK, NOK, PLN, CZK, HUF, RON. |
All products within one transmission must use the same currency, a mix is rejected. A zero price is never valid: if you operate a free-of-charge station, contact us instead of sending a zero.
curl -X POST https://api.h2-stations.eu/import/v2/signal/pricing/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '[
{
"hrs": "ABCDE",
"datetime": "2026-07-11T09:41:00+02:00",
"products": [
{"id": "PROD-700", "gross_price": "13.990"},
{"id": "PROD-350", "gross_price": "9.500", "net_price": "7.983", "currency": "EUR"}
]
}
]'
import datetime
import requests
TOKEN = "..." # your v2 token
transmission = {
"hrs": "ABCDE",
"datetime": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"products": [
{"id": "PROD-700", "gross_price": "13.990"},
{"id": "PROD-350", "gross_price": "9.500", "net_price": "7.983", "currency": "EUR"},
],
}
response = requests.post(
"https://api.h2-stations.eu/import/v2/signal/pricing/",
json=[transmission],
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=10,
)
response.raise_for_status()
Checking your assignments: active-stations¶
GET /import/v2/active-stations/ lists the stations your token is assigned to, grouped per signal type and per assignment state:
curl -H "Authorization: Bearer $TOKEN" https://api.h2-stations.eu/import/v2/active-stations/
{
"stations": {
"availability": {"live": ["ABCDE"], "testing": ["FGHIJ"]},
"usage": {"live": ["ABCDE"], "testing": []},
"hydrogen-storage": {"live": [], "testing": ["ABCDE"]},
"pricing": {"live": ["ABCDE"], "testing": []}
}
}
Assignments in v2 are per signal type. For each station and each of the four signal types, your system is either:
| State | What happens to your signals |
|---|---|
live |
Signals are processed and published. They drive the public status. |
testing |
Signals are accepted and validated end to end, but not published. Use this to verify your integration before going live. |
| not assigned | Signals are still accepted and visible to us for debugging, but they are not published and do not build up history. |
The same system can be live for availability and testing for pricing on the same station. The HRS operator controls these assignments, so if active-stations does not show a station you expect, contact the operator or H2-Stations.
Testing with dry_run¶
Set "dry_run": true on a transmission to validate it end to end (authentication, schema, identifier resolution, value ranges) without storing or publishing anything. A dry run that returns 204 confirms your payload would have been accepted.
Dry-run transmissions still count against the rate limit, and the dry_run flag applies per transmission, so one batch can mix real and dry-run entries.
Responses¶
204 No Content: success¶
All transmissions in the batch were accepted (or validated, for dry runs). The response body is empty. There is no 200 OK with a body.
Acceptance does not by itself publish anything. Publication depends on the per-type assignment state described under active-stations.
400 Bad Request: validation failure¶
At least one transmission failed validation and the whole batch was rejected, including entries that were individually valid. The body is a JSON array aligned with your request: an empty object {} for transmissions that passed, and an object mapping field names to error messages for those that failed.
[
{},
{
"nozzles": [
"Unknown nozzle identifier: NOZ-99."
]
}
]
Common validation errors and their causes:
| Error message (excerpt) | Cause |
|---|---|
Unknown station identifier: ... |
The hrs value does not match a registered station |
Unknown nozzle identifier / dispenser identifier / storage identifier / product identifier: ... |
The identifier is not registered, or the operator has not confirmed that part of the layout yet |
Duplicate nozzle reference / storage reference / product reference: ... |
The same entity appears twice in one transmission |
Duplicate station in batch: ... |
Two transmissions in one request address the same station |
The signal may not be older than 5 minutes. |
Stale datetime, often a catch-up burst after an outage |
The signal may not be from the future. |
Clock skew beyond the 30-second tolerance |
Provide exactly one of "id" or "dispenser_id". |
A nozzle entry carries both fields or neither |
Provide at least one of "remaining_kg" or "low_h2_storage". |
An empty storage entry |
Inconsistent low_h2_storage for storage ... |
Numeric inventory and boolean flag contradict each other at the 100 kg threshold |
Inconsistent price for product ... |
net_price exceeds gross_price |
Mixed currencies in one transmission: ... |
Products in one transmission use different currencies |
No fuelling option could be derived from the station layout; check the station registration. |
The station layout does not map to any fuelling option yet |
401 Unauthorized¶
The Authorization header is missing or malformed, the token is unknown or has been revoked, or it is a v1 token. The body is {"detail": "<message>"}. Nothing is processed.
429 Too Many Requests¶
You exceeded the rate limit of 300 requests per minute per signal originator (across all v2 endpoints together). The response carries a Retry-After header telling you how long to wait. If your fleet legitimately needs a higher rate, contact us.
500 / 503¶
500 Internal Server Error is an unexpected platform error, please report it. 503 Service Unavailable is temporary, retry with backoff.
Recommended cadence¶
- Availability and usage: send every minute, even when nothing changed. A station the platform has not heard from in 30 minutes falls back to
unknown, and the AFIR reporting cadence expects one signal per minute. - Pricing: send on change plus a daily heartbeat.
- Hydrogen storage: send on change, or on the availability cadence if that is simpler for your transmitter.
Remember the 5-minute freshness ceiling on datetime: after a network outage, send a fresh reading rather than replaying the queue.
Common integration questions¶
My identifiers are rejected but the operator says the layout is registered. Identifiers only resolve once the operator has confirmed (validated) the layout in the portal. Draft layouts are invisible to the API. Ask the operator to complete the confirmation step.
Signals return 204 but nothing shows on the map.
Check GET /import/v2/active-stations/. The station is probably in testing for that signal type, or not assigned to your system at all. Both are normal during onboarding.
Can I send several stations in one request? Yes, one transmission per station per request. But note the all-or-nothing batch semantics: one invalid transmission rejects the whole request. Many integrators prefer one station per request for simpler error handling.
Which currency codes are accepted?
EUR, CHF, GBP, DKK, SEK, NOK, PLN, CZK, HUF, RON. The default is EUR, and one transmission must stick to a single currency.
Is there a machine-readable schema for v2? Yes. The interactive reference for v2 is at /import/v2/doc/, with the OpenAPI document at /import/v2/doc/schema. The earlier versions stay available at /import/v1/doc/ and /import/v1.1/doc/.
Contact¶
h2-stations@clean-hydrogen.europa.eu