Virtual EMS API

Clés API et analyses Simulateur

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

NameInTypeRequiredDescription
emailbodystringyesA valid address (at most 200 characters). Case does not matter.
passwordbodystringyes10 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

NameInTypeRequiredDescription
emailbodystringyes
passwordbodystringyes

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

NameInTypeRequiredDescription
refresh_tokenbodystringyesThe 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

NameInTypeRequiredDescription
refresh_tokenbodystringyes

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

NameInTypeRequiredDescription
old_passwordbodystringyes
new_passwordbodystringyes10 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

NameInTypeRequiredDescription
prmbodystringyes14 digits.
tokenbodystringyesThe Conso API token (20-400 characters, one word).
namebodystringnoAt most 80 characters.
addressbodystringnoAt most 200 characters.
latbodynumberno
lonbodynumberno

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

NameInTypeRequiredDescription
prmpathstringyesThe 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

NameInTypeRequiredDescription
prmpathstringyes

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

NameInTypeRequiredDescription
prmpathstringyes

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

NameInTypeRequiredDescription
prmpathstringyes

JSON body

NameInTypeRequiredDescription
addressbodystringnoAt most 200 characters.
latbodynumberno-90 to 90.
lonbodynumberno-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

NameInTypeRequiredDescription
prmpathstringyes
daysqueryintegerno1-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

NameInTypeRequiredDescription
prmpathstringyes

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

NameInTypeRequiredDescription
prmpathstringyes
daysqueryintegerno1-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

NameInTypeRequiredDescription
qquerystringyesAddress, 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

NameInTypeRequiredDescription
prmbodystringnoA home of the user (its consumption is used). Either prm or annual_kwh.
annual_kwhbodynumbernoYearly consumption (500-100000) when no home is given: a standard household day is scaled to it.
latbodynumberyesLatitude, 41-52 (metropolitan France).
lonbodynumberyesLongitude, -6 to 10.
roof_m2bodynumberyesRoof surface, 5-1000 m2.
usable_roof_pctbodynumbernoUsable share of the roof, 20-100 (default 75).
orientationbodystringnoS, SE, SW, E, W, NE, NW, N (default S).
tiltbodynumbernoDegrees, 0-90 (default 30).
shadingbodystringnonone (default), some (-10 %) or lot (-25 %).
shading_pctbodynumbernoLoss 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_wpbodynumbernoPower of a panel, 100-800 Wc (default 400).
panel_area_m2bodynumbernoArea of a panel, 0.8-3 m2 (default 1.8).
panelsbodyintegernoNumber of panels, 0 to what the roof holds (default: the maximum).
battery_kwhbodynumbernoUsable capacity, 0-100 kWh (default 0: no battery).
battery_kwbodynumbernoCharge and discharge power, 0.5-50 kW (default 3).
battery_efficiencybodynumbernoRound trip, 0.5-1 (default 0.9).
battery_reserve_pctbodynumbernoNever discharged below this share, 0-50 (default 10).
buy_eur_kwhbodynumbernoPurchase price, 0-2 EUR/kWh (default 0.20).
sell_eur_kwhbodynumbernoSale price, 0-2 EUR/kWh (default 0.07).
cost_eur_kwpbodynumbernoCost of the panels, EUR per kWp (default 1800).
cost_eur_battery_kwhbodynumbernoCost of the battery, EUR per kWh (default 700).
degradation_pctbodynumbernoYearly loss of production, 0-3 % (default 0.5).
price_change_pctbodynumbernoYearly change of the price, -5 to 10 % (default 0).
extra_loadsbodyarraynoExtra 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 prm nor annual_kwh is 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

NameInTypeRequiredDescription
namebodystringyes1-60 characters.
scopebodystringnowrite (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

NameInTypeRequiredDescription
key_idpathintegeryes

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

NameInTypeRequiredDescription
key_idpathintegeryes

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

NameInTypeRequiredDescription
daysqueryintegerno1-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

NameInTypeRequiredDescription
key_idqueryintegernoOnly this key.
limitqueryintegerno1-200, default 50.
offsetqueryintegerno

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

NameInTypeRequiredDescription
key_idqueryintegernoOnly the analyses brought by this key.
regionquerystringnoOnly this region.
qquerystringnoText searched in the address, region, sub-region and building key (accents and case: as stored; % and _ are plain characters).
min_scorequerynumbernoScore of at least this value (0-100).
statusquerystringnoOnly this status (saved, duplicate, unsaved, error).
uniquequerybooleannotrue: only the latest analysis of each building.
limitqueryintegernoPage size (1-200).
offsetqueryintegernoRows to skip.
fullquerybooleannotrue: 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

NameInTypeRequiredDescription
analysis_idpathintegeryes

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

NameInTypeRequiredDescription
analysis_idpathintegeryes
tiltquerynumbernoTilt now used by the page, 0-90 (default 30): the roof area depends on it.
usable_roof_pctquerynumbernoUsable share of the roof, 20-100 (default 75): for panels_max.
panel_area_m2querynumbernoArea 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

NameInTypeRequiredDescription
analysis_idpathintegeryes

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

NameInTypeRequiredDescription
itemsbodyarrayyes1 to 50 items (see the example).
sourcebodystringnoFree text, e.g. appscript.
items[].keybodystringnoIdentifier of the building (the script uses lat,lon). Used by unique=true.
items[].statusbodystringnoe.g. saved, duplicate, unsaved, error.
items[].region / sub_region / addressbodystringno
items[].scorebodynumbernoSolar potential, 0-100.
items[].lat / lonbodynumberno
items[].messagebodystringnoKept in data as Message.
items[].businessesbodyarraynoNames (at most 20), kept in data as Businesses.
items[].databodyobjectnoAny 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 write right.
  • 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

NameInTypeRequiredDescription
regionquerystringnoOnly this region.
qquerystringnoText searched in the address, region, sub-region and building key (accents and case: as stored; % and _ are plain characters).
min_scorequerynumbernoScore of at least this value (0-100).
statusquerystringnoOnly this status (saved, duplicate, unsaved, error).
uniquequerybooleannotrue: only the latest analysis of each building.
limitqueryintegernoPage size (1-200).
offsetqueryintegernoRows 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 read right.
  • 429 More than 120 calls a minute with this key.