Base address and format
Everything is under one address, for example https://82.66.78.69:8010 (until the Let's Encrypt certificate of the IP address is installed, the server uses a self-signed certificate: with curl add -k).
Requests and answers are JSON (Content-Type: application/json). Timestamps named ...at, ts or last_ts are Unix seconds (UTC).
Error answers are {"detail": "..."}. The messages are written in French, for the pages: program on the status code, show the text.
Two ways to authenticate
Signed-in user (the pages): Authorization: Bearer <access_token>. POST /api/auth/login or /register returns an access token (valid 7 days) and a refresh token (valid 1 year). When the access token has expired, POST /api/auth/refresh gives a new pair; each refresh token works once. Changing the password or logout-all ends every session at once.
API key (programs): X-API-Key: vems_..., only on /api/webhook/* and /api/v1/*. A key is made in the dashboard (or POST /api/keys), is shown once and has the right write, read or both. A key never opens the other routes, and a sign-in token never opens the key routes.
A home, key or analysis of another user always answers 404, like one that does not exist.
Limits
API keys: 120 calls a minute per key (429 beyond), 10 active keys per user, bodies up to 1 MB, 50 items a call, 20 000 analyses kept per user (the oldest go first). 20 wrong keys in 10 minutes from one address block that address for a while. Every call with a valid key, accepted or refused, is logged for 90 days.
Accounts: 5 failed sign-ins per address and e-mail (20 per address) in 15 minutes, 10 sign-ups an hour per address, 5 homes per user.
nginx in front also limits the rate per address (about 10 requests a second, /api/auth/* 30 a minute).
Quick start
# 1. sign in and keep the access token
curl -sk -X POST https://HOST/api/auth/login -H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a long password"}'
export ACCESS_TOKEN=... # the access_token of the answer
# 2. make a key that may send analyses (the key is in the answer, once)
curl -sk -X POST https://HOST/api/keys -H "Authorization: Bearer $ACCESS_TOKEN" -H "Content-Type: application/json" \
-d '{"name":"my script","scope":"write"}'
export VEMS_KEY=... # the "key" field of the answer
# 3. check it, then send an analysis
curl -sk https://HOST/api/webhook/ping -H "X-API-Key: $VEMS_KEY"
curl -sk -X POST https://HOST/api/webhook/analysis -H "X-API-Key: $VEMS_KEY" -H "Content-Type: application/json" \
-d '{"items":[{"key":"test","address":"1 rue Exemple","region":"Calais","score":70,"data":{"Roof Surface (m2)":120}}]}'Accounts and sessions
Sign-up, sign-in and the tokens of a session.
GET /api/health no authentication
Is the server up.
Example
curl -sk -X GET "https://HOST/api/health"
Answer 200
{
"ok": true
}GET /api/auth/config no authentication
Whether sign-up is open (VEMS_SIGNUP).
Example
curl -sk -X GET "https://HOST/api/auth/config"
Answer 200
{
"signup": true
}POST /api/auth/register no authentication
Creates an account and signs it in.
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
email | body | string | yes | A valid address (at most 200 characters). Case does not matter. |
password | body | string | yes | 10 to 200 characters, not the address itself. |
Example
curl -sk -X POST "https://HOST/api/auth/register" \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a long password"}'Answer 200
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 604800,
"refresh_expires_in": 31536000,
"user": {
"id": 1,
"email": "you@example.com"
}
}Errors
- 403 Sign-up is closed.
- 409 An account already exists with this address.
- 422 Invalid address or password.
- 429 More than 10 sign-ups an hour from one address.
POST /api/auth/login no authentication
Signs in. Wrong address and wrong password give the same answer.
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
email | body | string | yes | |
password | body | string | yes |
Example
curl -sk -X POST "https://HOST/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"you@example.com","password":"a long password"}'Answer 200
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 604800,
"refresh_expires_in": 31536000,
"user": {
"id": 1,
"email": "you@example.com"
}
}Errors
- 401 Incorrect credentials.
- 429 5 failures for one address and e-mail, or 20 for one address, in 15 minutes.
POST /api/auth/refresh no authentication
Exchanges a refresh token for a new pair. The refresh token you send stops working at once (it is replaced at each use).
Showing an already used refresh token again after more than a minute means it was copied: every session of the user ends.
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
refresh_token | body | string | yes | The refresh token (valid 1 year). |
Example
curl -sk -X POST "https://HOST/api/auth/refresh" \
-H "Content-Type: application/json" \
-d '{"refresh_token":"eyJhbGciOiJIUzI1NiIs..."}'Answer 200
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 604800,
"refresh_expires_in": 31536000,
"user": {
"id": 1,
"email": "you@example.com"
}
}Errors
- 401 Expired, already used, revoked or invalid token.
POST /api/auth/logout no authentication
Ends the session of a refresh token. Always answers 200, even for a token that is already invalid.
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
refresh_token | body | string | yes |
Example
curl -sk -X POST "https://HOST/api/auth/logout" \
-H "Content-Type: application/json" \
-d '{"refresh_token":"eyJhbGciOiJIUzI1NiIs..."}'Answer 200
{
"ok": true
}POST /api/auth/logout-all signed-in user (Bearer)
Ends every session of the user, on every device.
Example
curl -sk -X POST "https://HOST/api/auth/logout-all" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"ok": true
}GET /api/auth/me signed-in user (Bearer)
The signed-in user.
Example
curl -sk -X GET "https://HOST/api/auth/me" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"id": 1,
"email": "you@example.com"
}Errors
- 401 Missing, invalid or expired access token.
POST /api/auth/password signed-in user (Bearer)
Changes the password. Every other session ends; the answer holds a new pair of tokens.
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
old_password | body | string | yes | |
new_password | body | string | yes | 10 to 200 characters. |
Example
curl -sk -X POST "https://HOST/api/auth/password" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"old_password":"a long password","new_password":"another long password"}'Answer 200
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer",
"expires_in": 604800,
"refresh_expires_in": 31536000,
"user": {
"id": 1,
"email": "you@example.com"
}
}Errors
- 403 The current password is wrong.
- 422 The new password is not accepted.
Page settings
What the pages need to know.
GET /api/app/config signed-in user (Bearer)
Settings and limits shown by the pages.
Example
curl -sk -X GET "https://HOST/api/app/config" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"appscript_url": "https://script.google.com/macros/s/.../exec",
"webhook_path": "/api/webhook/analysis",
"max_keys": 10,
"key_rate_per_minute": 120,
"call_log_days": 90,
"max_analyses": 20000
}Homes and consumption
The Linky homes of the signed-in user and their Enedis consumption.
GET /api/homes signed-in user (Bearer)
The homes of the user.
Example
curl -sk -X GET "https://HOST/api/homes" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
[
{
"prm": "12345678901234",
"name": "Maison",
"address": "12 rue de la Paix, Paris",
"lat": 48.87,
"lon": 2.33,
"has_token": true,
"points": 35040,
"first": "2024-11-12T00:00:00+01:00",
"last": "2026-11-11T23:30:00+01:00",
"sync": {
"state": "done",
"windows_done": 105,
"windows_total": 105,
"rows": 35040,
"message": "",
"...": "and the summary of the last import"
}
}
]POST /api/homes signed-in user (Bearer)
Links a home: checks the PRM and the token with one call to the Conso API, stores the token and starts the import of the history.
The token is stored and never returned by the API. At most 5 homes per user (VEMS_MAX_HOMES).
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | body | string | yes | 14 digits. |
token | body | string | yes | The Conso API token (20-400 characters, one word). |
name | body | string | no | At most 80 characters. |
address | body | string | no | At most 200 characters. |
lat | body | number | no | |
lon | body | number | no |
Example
curl -sk -X POST "https://HOST/api/homes" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"prm":"12345678901234","token":"<token of conso.boris.sh>","name":"Maison"}'Answer 200
{
"prm": "12345678901234",
"verified": true,
"message": "Connecté. L'import de l'historique a commencé."
}Errors
- 400 Enedis refused the token or the request.
- 409 The limit of homes is reached.
- 422 Invalid PRM or token.
- 502 The Conso API did not answer.
GET /api/homes/{prm} signed-in user (Bearer)
One home, with the state of its import.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | path | string | yes | The 14 digits. |
Example
curl -sk -X GET "https://HOST/api/homes/12345678901234" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"prm": "12345678901234",
"name": "Maison",
"address": "12 rue de la Paix, Paris",
"lat": 48.87,
"lon": 2.33,
"has_token": true,
"points": 35040,
"first": "2024-11-12T00:00:00+01:00",
"last": "2026-11-11T23:30:00+01:00",
"sync": {
"state": "done",
"windows_done": 105,
"windows_total": 105,
"rows": 35040,
"message": "",
"...": "and the summary of the last import"
}
}Errors
- 404 Unknown home (or a home of another user).
DELETE /api/homes/{prm} signed-in user (Bearer)
Forgets a home: its readings and its token are deleted.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | path | string | yes |
Example
curl -sk -X DELETE "https://HOST/api/homes/12345678901234" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"deleted": "12345678901234"
}Errors
- 404 Unknown home.
POST /api/homes/{prm}/sync signed-in user (Bearer)
Starts the update of the consumption (from the day of the last reading to today) and returns the home.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | path | string | yes |
Example
curl -sk -X POST "https://HOST/api/homes/12345678901234/sync" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"prm": "12345678901234",
"name": "Maison",
"address": "12 rue de la Paix, Paris",
"lat": 48.87,
"lon": 2.33,
"has_token": true,
"points": 35040,
"first": "2024-11-12T00:00:00+01:00",
"last": "2026-11-11T23:30:00+01:00",
"sync": {
"state": "done",
"windows_done": 105,
"windows_total": 105,
"rows": 35040,
"message": "",
"...": "and the summary of the last import"
}
}Errors
- 400 No token is stored for this home.
- 404 Unknown home.
POST /api/homes/{prm}/location signed-in user (Bearer)
Sets the address and the coordinates of a home.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | path | string | yes |
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
address | body | string | no | At most 200 characters. |
lat | body | number | no | -90 to 90. |
lon | body | number | no | -180 to 180. |
Example
curl -sk -X POST "https://HOST/api/homes/12345678901234/location" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"address":"12 rue de la Paix, Paris","lat":48.87,"lon":2.33}'Answer 200
{
"prm": "12345678901234",
"name": "Maison",
"address": "12 rue de la Paix, Paris",
"lat": 48.87,
"lon": 2.33,
"has_token": true,
"points": 35040,
"first": "2024-11-12T00:00:00+01:00",
"last": "2026-11-11T23:30:00+01:00",
"sync": {
"state": "done",
"windows_done": 105,
"windows_total": 105,
"rows": 35040,
"message": "",
"...": "and the summary of the last import"
}
}Errors
- 404 Unknown home.
GET /api/homes/{prm}/consumption signed-in user (Bearer)
Hourly consumption of the last days (for charts).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | path | string | yes | |
days | query | integer | no | 1-400, default 14. |
Example
curl -sk -X GET "https://HOST/api/homes/12345678901234/consumption?days=3" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"hours": [
{
"t": "2026-11-09 00:00",
"kwh": 0.231
},
{
"t": "2026-11-09 01:00",
"kwh": 0.188
}
],
"total_kwh": 80.4
}Errors
- 404 Unknown home.
GET /api/homes/{prm}/backtest signed-in user (Bearer)
How good the prediction is: the last 28 complete days are hidden, predicted from the rest and compared.
available is false (with a reason) when there are fewer than 88 complete days.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | path | string | yes |
Example
curl -sk -X GET "https://HOST/api/homes/12345678901234/backtest" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"available": true,
"holdout_days": 28,
"train_days": 640,
"daily_error_pct": 18.2,
"daily_wape_pct": 12.9,
"hourly_mae_kwh": 0.091,
"bias_pct": -1.4,
"estimated_hours_pct": 0.0,
"from": "2026-10-14",
"to": "2026-11-10"
}Errors
- 404 Unknown home.
GET /api/homes/{prm}/diagnose signed-in user (Bearer)
One call to the Conso API with the stored token, answering only with the shape of what came back (no values, no token).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | path | string | yes | |
days | query | integer | no | 1-7, default 3. |
Example
curl -sk -X GET "https://HOST/api/homes/12345678901234/diagnose?days=3" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"asked": "2026-11-09 to 2026-11-12",
"result": "answer",
"readings_found": 144,
"first_raw_stamp_time": "00:30:00",
"last_raw_stamp_time": "00:00:00",
"days_covered": 3,
"shape": {
"...": "the structure of the payload"
}
}Errors
- 404 Unknown home, or no token stored.
Calculation
Address lookup and the solar and battery simulation.
GET /api/geocode signed-in user (Bearer)
Address to coordinates (French public address service).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
q | query | string | yes | Address, town or postcode. |
Example
curl -sk -X GET "https://HOST/api/geocode?q=12%20rue%20de%20la%20Paix%20Paris" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"lat": 48.8693,
"lon": 2.3314,
"label": "12 Rue de la Paix 75002 Paris"
}Errors
- 404 No address found.
- 502 The address service did not answer.
POST /api/simulate signed-in user (Bearer)
The simulation: production, self-consumption, savings, by season, month, hour and day, and over 25 years.
The first call for a place can take one to five minutes: the solar data (PVGIS) is fetched in the background and the answer is 202 with {"status": "pending"}. Call again every few seconds until the answer is 200; the data is then kept and the same place answers at once. The answer describes the configuration chosen: with the battery if battery_kwh is above 0 (active says which).
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
prm | body | string | no | A home of the user (its consumption is used). Either prm or annual_kwh. |
annual_kwh | body | number | no | Yearly consumption (500-100000) when no home is given: a standard household day is scaled to it. |
lat | body | number | yes | Latitude, 41-52 (metropolitan France). |
lon | body | number | yes | Longitude, -6 to 10. |
roof_m2 | body | number | yes | Roof surface, 5-1000 m2. |
usable_roof_pct | body | number | no | Usable share of the roof, 20-100 (default 75). |
orientation | body | string | no | S, SE, SW, E, W, NE, NW, N (default S). |
tilt | body | number | no | Degrees, 0-90 (default 30). |
shading | body | string | no | none (default), some (-10 %) or lot (-25 %). |
shading_pct | body | number | no | Loss of production by shading, 0-60 %, as a measured value (for example from an analysis of the roof). When it is given it replaces shading. |
panel_wp | body | number | no | Power of a panel, 100-800 Wc (default 400). |
panel_area_m2 | body | number | no | Area of a panel, 0.8-3 m2 (default 1.8). |
panels | body | integer | no | Number of panels, 0 to what the roof holds (default: the maximum). |
battery_kwh | body | number | no | Usable capacity, 0-100 kWh (default 0: no battery). |
battery_kw | body | number | no | Charge and discharge power, 0.5-50 kW (default 3). |
battery_efficiency | body | number | no | Round trip, 0.5-1 (default 0.9). |
battery_reserve_pct | body | number | no | Never discharged below this share, 0-50 (default 10). |
buy_eur_kwh | body | number | no | Purchase price, 0-2 EUR/kWh (default 0.20). |
sell_eur_kwh | body | number | no | Sale price, 0-2 EUR/kWh (default 0.07). |
cost_eur_kwp | body | number | no | Cost of the panels, EUR per kWp (default 1800). |
cost_eur_battery_kwh | body | number | no | Cost of the battery, EUR per kWh (default 700). |
degradation_pct | body | number | no | Yearly loss of production, 0-3 % (default 0.5). |
price_change_pct | body | number | no | Yearly change of the price, -5 to 10 % (default 0). |
extra_loads | body | array | no | Extra uses: [{"kind": "ev" | "heat_pump" | "flat", "kwh_per_year": 0-50000}]. |
Example
curl -sk -X POST "https://HOST/api/simulate" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"lat":48.85,"lon":2.35,"roof_m2":40,"orientation":"S","tilt":30,"annual_kwh":4500,"battery_kwh":5}'Answer 200
{
"status": "ok",
"installation": {
"panels": 16,
"max_panels": 16,
"kwp": 6.4,
"tilt": 30.0,
"orientation": "S",
"shading_pct": 0
},
"consumption": {
"annual_kwh": 4500.0,
"base_kwh": 4500.0,
"extra_kwh": 0.0,
"source": "standard",
"history_days": 365,
"estimated_months": []
},
"no_battery": {
"production_kwh": 14194.9,
"self_consumption_pct": 9.7,
"self_sufficiency_pct": 30.6,
"import_kwh": 3122.2,
"export_kwh": 12817.2,
"savings_per_year_eur": 1173.0,
"capex_eur": 11520.0,
"payback_years": 10.0,
"...": "..."
},
"with_battery": {
"...": "same fields, plus battery_extra_savings_eur and battery_payback_years (null without a battery)"
},
"active": "with_battery",
"monthly": {
"consumption": [
"12 values"
],
"production": [
"12 values"
],
"import_no_battery": [
"12 values"
],
"import_with_battery": [
"12 values"
]
},
"typical_day": {
"year": {
"consumption": [
"24 values"
],
"production": [
"..."
],
"...": "..."
},
"winter": {},
"spring": {},
"summer": {},
"autumn": {},
"1": {},
"...": "the same for the months \"2\" to \"12\""
},
"breakdown": {
"months": [
"12 rows"
],
"seasons": [
"4 rows"
],
"hours": [
"24 rows"
],
"days": {
"date": [
"365 dates"
],
"savings_eur": [
"365 values"
],
"...": "..."
}
},
"lifetime": {
"years": [
{
"year": 1,
"savings_eur": 1363,
"cumulative_net_eur": -13657
}
],
"payback_years": 11.3,
"net_gain_eur": 9000,
"degradation_pct": 0.5,
"price_change_pct": 0.0
},
"prediction": {
"consumption_kwh": 4500.0,
"reduction_pct": 65.2,
"savings_eur": 1363.0,
"best_month": {
"name": "janvier",
"savings_eur": 118.76
},
"...": "..."
},
"assumptions": {
"solar_data": "...",
"consumption": "...",
"battery": "...",
"prices": "...",
"not_included": "..."
}
}Answer 202 while the solar data is being fetched: {"status": "pending", "message": "..."}. Energies are kWh, money is EUR, months and hours are local (Paris) time; the year is the 2019 reference year.
Errors
- 202 Pending: call again in a few seconds.
- 404 Unknown home.
- 422 A value is out of range, or neither
prmnorannual_kwhis given (the message names the field). - 502 PVGIS did not answer, or too many solar calculations are running.
API keys
Make and manage the keys, and see what they did.
GET /api/keys signed-in user (Bearer)
The keys of the user with what each one did (calls in total, over 24 hours and 7 days, errors, analyses received).
Example
curl -sk -X GET "https://HOST/api/keys" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
[
{
"id": 1,
"name": "Apps Script",
"hint": "vems_XEKw...wQ18",
"scope": "write",
"created_at": 1794643140,
"last_used_at": 1794643200,
"revoked_at": null,
"active": true,
"calls_total": 4,
"calls_24h": 4,
"calls_7d": 4,
"calls_errors": 2,
"analyses": 1
}
]POST /api/keys signed-in user (Bearer)
Makes a key. The key itself is in this answer and nowhere else: only its hash is stored.
Rights: write (send analyses to the webhook), read (read the analyses), read,write. At most 10 active keys per user (VEMS_MAX_KEYS).
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
name | body | string | yes | 1-60 characters. |
scope | body | string | no | write (default), read or read,write. |
Example
curl -sk -X POST "https://HOST/api/keys" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Apps Script","scope":"write"}'Answer 200
{
"id": 1,
"name": "Apps Script",
"hint": "vems_XEKw...wQ18",
"scope": "write",
"created_at": 1794643140,
"last_used_at": 1794643200,
"revoked_at": null,
"active": true,
"calls_total": 4,
"calls_24h": 4,
"calls_7d": 4,
"calls_errors": 2,
"analyses": 1,
"key": "vems_XEKw8R1H0rXapbQjyQA1zRP55CD1n5MhhH8nbcNwQ18"
}Errors
- 409 The limit of active keys is reached.
- 422 Missing name or unknown rights.
POST /api/keys/{key_id}/revoke signed-in user (Bearer)
Revokes a key: it is refused at once. The key stays listed.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
key_id | path | integer | yes |
Example
curl -sk -X POST "https://HOST/api/keys/1/revoke" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"id": 1,
"name": "Apps Script",
"hint": "vems_XEKw...wQ18",
"scope": "write",
"created_at": 1794643140,
"last_used_at": 1794643200,
"revoked_at": 1794650000,
"active": false,
"calls_total": 4,
"calls_24h": 4,
"calls_7d": 4,
"calls_errors": 2,
"analyses": 1
}Errors
- 404 Unknown key.
DELETE /api/keys/{key_id} signed-in user (Bearer)
Deletes a key and its call log. The analyses it brought are kept (with its name).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
key_id | path | integer | yes |
Example
curl -sk -X DELETE "https://HOST/api/keys/1" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"deleted": 1
}Errors
- 404 Unknown key.
GET /api/keys/analytics signed-in user (Bearer)
Analytics of the keys and of the analyses received.
Days are UTC days; per_day has one entry per day of the period, zeros included.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
days | query | integer | no | 1-90, default 30. |
Example
curl -sk -X GET "https://HOST/api/keys/analytics?days=7" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"days": 7,
"calls": 6,
"errors": 3,
"avg_ms": 1,
"bytes_in": 350,
"keys_active": 2,
"per_day": [
{
"day": "2026-11-14",
"ok": 3,
"client_error": 3,
"server_error": 0
}
],
"per_key": [
{
"id": 1,
"name": "Apps Script",
"calls": 4,
"errors": 2,
"avg_ms": 2,
"last_ts": 1794643140
}
],
"endpoints": [
{
"method": "POST",
"path": "/api/webhook/analysis",
"calls": 3,
"errors": 2
}
],
"analyses": {
"total": 1,
"buildings": 1,
"avg_score": 81.0,
"last_at": 1794643140,
"regions": [
{
"region": "Calais",
"count": 1
}
],
"score_buckets": [
0,
0,
0,
0,
0,
0,
0,
0,
1,
0
]
}
}GET /api/keys/calls signed-in user (Bearer)
The log of the calls made with the keys (accepted and refused), newest first, kept 90 days.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
key_id | query | integer | no | Only this key. |
limit | query | integer | no | 1-200, default 50. |
offset | query | integer | no |
Example
curl -sk -X GET "https://HOST/api/keys/calls?limit=2" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"total": 6,
"items": [
{
"id": 6,
"key_id": 1,
"key_name": "Apps Script",
"ts": 1794643140,
"method": "GET",
"path": "/api/v1/analyses",
"status": 403,
"ms": 0,
"bytes_in": 0,
"ip": "203.0.113.7"
}
]
}Analyses received
What the analysis webhook stored for the signed-in user.
GET /api/analyses signed-in user (Bearer)
The analyses received, newest first (without their data: use the detail route).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
key_id | query | integer | no | Only the analyses brought by this key. |
region | query | string | no | Only this region. |
q | query | string | no | Text searched in the address, region, sub-region and building key (accents and case: as stored; % and _ are plain characters). |
min_score | query | number | no | Score of at least this value (0-100). |
status | query | string | no | Only this status (saved, duplicate, unsaved, error). |
unique | query | boolean | no | true: only the latest analysis of each building. |
limit | query | integer | no | Page size (1-200). |
offset | query | integer | no | Rows to skip. |
full | query | boolean | no | true: add the data of each analysis (used by the CSV export). |
Example
curl -sk -X GET "https://HOST/api/analyses?region=Calais&min_score=60&limit=25" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"total": 1,
"items": [
{
"id": 1,
"key_id": 1,
"key_name": "Apps Script",
"batch_id": "ea2c57b10179416ebf173b1748ec9264",
"received_at": 1794643140,
"building_key": "50.951000,1.851000",
"region": "Calais",
"sub_region": "Centre",
"address": "1 rue du Test, 62100 Calais",
"lat": 50.951,
"lon": 1.851,
"score": 81.0,
"status": "saved"
}
]
}GET /api/analyses/{analysis_id} signed-in user (Bearer)
One analysis with everything that was received.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
analysis_id | path | integer | yes |
Example
curl -sk -X GET "https://HOST/api/analyses/1" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"id": 1,
"key_id": 1,
"key_name": "Apps Script",
"batch_id": "ea2c57b10179416ebf173b1748ec9264",
"received_at": 1794643140,
"building_key": "50.951000,1.851000",
"region": "Calais",
"sub_region": "Centre",
"address": "1 rue du Test, 62100 Calais",
"lat": 50.951,
"lon": 1.851,
"score": 81.0,
"status": "saved",
"data": {
"Roof Surface (m2)": 120.5,
"Roof Height (m)": 9,
"Shading Ratio": 25,
"Businesses": [
"Boulangerie Test"
]
}
}Errors
- 404 Unknown analysis.
GET /api/analyses/{analysis_id}/simulation signed-in user (Bearer)
The inputs of the simulation that this analysis can give, each with the rule that produced it.
Fills step 2 of the page. Filled: lat, lon, address; orientation (the better of the two slopes of Roof Faces); roof_m2 (the footprint divided by 2 and by cos(tilt): one slope carries the panels; a roof tilted under 10 degrees keeps the whole footprint); shading_pct (the Shading Ratio x 0.25: all four sun positions shaded is the 25 % of the choice lot); annual_kwh (the average residential consumption of the commune, not of the building). panels_max is the most the roof holds. Not given by an analysis, so listed in kept: tilt, battery, extra loads, panel and price assumptions. A field that cannot be derived is left out of inputs and explained in warnings. Numbers sent as text ("4 522", "98,9") are understood.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
analysis_id | path | integer | yes | |
tilt | query | number | no | Tilt now used by the page, 0-90 (default 30): the roof area depends on it. |
usable_roof_pct | query | number | no | Usable share of the roof, 20-100 (default 75): for panels_max. |
panel_area_m2 | query | number | no | Area of a panel, 0.8-3 (default 1.8): for panels_max. |
Example
curl -sk -X GET "https://HOST/api/analyses/1/simulation?tilt=30" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"analysis": {
"id": 1,
"address": "55 Rue Gaston Mangin 91230 Montgeron",
"region": "Paris",
"sub_region": "Zone S6",
"commune": "Montgeron",
"score": 81.0,
"status": "saved",
"received_at": 1794643140,
"key_name": "Apps Script",
"roof_surface_m2": 98.9,
"roof_height_m": 4.8,
"roof_faces": "S / N",
"shading_ratio_pct": 0.0,
"worst_shading_case": "no significant roof shading detected",
"sunshine_hours": 2908.3,
"commune_annual_kwh": 4522.0,
"consumption_year": 2017.0
},
"inputs": {
"lat": 48.711584,
"lon": 2.44855,
"address": "55 Rue Gaston Mangin 91230 Montgeron",
"orientation": "S",
"roof_m2": 57.1,
"shading_pct": 0.0,
"annual_kwh": 4522,
"...": "only the fields that could be derived"
},
"panels_max": 23,
"explain": [
{
"field": "roof_m2",
"text": "Surface 57,1 m² : l'emprise au sol (98,9 m²) divisée par 2, ..."
}
],
"warnings": [],
"kept": [
"tilt",
"battery_kwh",
"extra_loads",
"panel_wp",
"panel_area_m2",
"usable_roof_pct",
"prices"
]
}The texts of explain and warnings are in French, for the page.
Errors
- 404 Unknown analysis (or one of another user).
DELETE /api/analyses/{analysis_id} signed-in user (Bearer)
Deletes an analysis.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
analysis_id | path | integer | yes |
Example
curl -sk -X DELETE "https://HOST/api/analyses/1" \ -H "Authorization: Bearer $ACCESS_TOKEN"
Answer 200
{
"deleted": 1
}Errors
- 404 Unknown analysis.
For programs (API key)
The webhook the Apps Script calls, and the read API. These routes need an API key, never a sign-in.
GET /api/webhook/ping API key (X-API-Key)
Checks a key without sending anything. Works with any right.
Example
curl -sk -X GET "https://HOST/api/webhook/ping" \ -H "X-API-Key: $VEMS_KEY"
Answer 200
{
"ok": true,
"key": "Apps Script",
"scope": "write"
}Errors
- 401 Missing, wrong or revoked key.
POST /api/webhook/analysis API key (X-API-Key)
Sends the analyses of one or several buildings. They are stored for the owner of the key and shown in the dashboard.
Needs a key with the right write. Each item is cut to size and cleaned: texts are cut (address 300 characters, sub-region 120, region 100, building key 64, status 20, message 500, any other value 1000), numbers must be finite, a score outside 0-100 or a lat/lon outside the valid range is dropped, at most 200 fields are kept. data may be replaced by headers + values (two lists). When address, region, lat, lon or score are missing they are read from data (Address, Commune, Latitude, Longitude, Solar Potential Score (0-100)). Items with nothing usable are counted in ignored.
JSON body
| Name | In | Type | Required | Description |
|---|---|---|---|---|
items | body | array | yes | 1 to 50 items (see the example). |
source | body | string | no | Free text, e.g. appscript. |
items[].key | body | string | no | Identifier of the building (the script uses lat,lon). Used by unique=true. |
items[].status | body | string | no | e.g. saved, duplicate, unsaved, error. |
items[].region / sub_region / address | body | string | no | |
items[].score | body | number | no | Solar potential, 0-100. |
items[].lat / lon | body | number | no | |
items[].message | body | string | no | Kept in data as Message. |
items[].businesses | body | array | no | Names (at most 20), kept in data as Businesses. |
items[].data | body | object | no | Any plain values: the columns of the analysis (roof, shading, sun...). |
Example
curl -sk -X POST "https://HOST/api/webhook/analysis" \
-H "X-API-Key: $VEMS_KEY" \
-H "Content-Type: application/json" \
-d '{"source":"appscript","items":[{"key":"50.951000,1.851000","status":"saved","region":"Calais","sub_region":"Centre","address":"1 rue du Test, 62100 Calais","score":81,"lat":50.951,"lon":1.851,"message":"","businesses":["Boulangerie Test"],"data":{"Roof Surface (m2)":120.5,"Roof Height (m)":9,"Roof Faces":"S / N","Shading Ratio":25,"Avg Annual Consumption (kWh)":4522}}]}'Answer 200
{
"ok": true,
"batch_id": "ea2c57b10179416ebf173b1748ec9264",
"stored": 1,
"ignored": 0
}Errors
- 400 The body is not valid JSON.
- 401 Missing, wrong or revoked key.
- 403 The key has no
writeright. - 413 More than 1 MB.
- 422 Not an object, no
items, more than 50 items, nothing usable, or an item above 64 KB of data. - 429 More than 120 calls a minute with this key.
GET /api/v1/analyses API key (X-API-Key)
The analyses of the owner of the key, with their data. Needs the right read.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
region | query | string | no | Only this region. |
q | query | string | no | Text searched in the address, region, sub-region and building key (accents and case: as stored; % and _ are plain characters). |
min_score | query | number | no | Score of at least this value (0-100). |
status | query | string | no | Only this status (saved, duplicate, unsaved, error). |
unique | query | boolean | no | true: only the latest analysis of each building. |
limit | query | integer | no | Page size (1-200). |
offset | query | integer | no | Rows to skip. |
Example
curl -sk -X GET "https://HOST/api/v1/analyses?min_score=60&limit=50" \ -H "X-API-Key: $VEMS_KEY"
Answer 200
{
"total": 1,
"items": [
{
"id": 1,
"key_id": 1,
"key_name": "Apps Script",
"batch_id": "ea2c57b10179416ebf173b1748ec9264",
"received_at": 1794643140,
"building_key": "50.951000,1.851000",
"region": "Calais",
"sub_region": "Centre",
"address": "1 rue du Test, 62100 Calais",
"lat": 50.951,
"lon": 1.851,
"score": 81.0,
"status": "saved",
"data": {
"Roof Surface (m2)": 120.5,
"Roof Height (m)": 9,
"Shading Ratio": 25,
"Businesses": [
"Boulangerie Test"
]
}
}
]
}Errors
- 401 Missing, wrong or revoked key.
- 403 The key has no
readright. - 429 More than 120 calls a minute with this key.