Send a schedule. Get back what was delivered.

Evora runs one controller in front of every battery it manages. You are the optimizer: you decide what the battery should do, Evora's edge does it inside the owner's safety limits, and Evora tells you, interval by interval, what actually happened.

You send a plan
POST /api/v1/ems/schedules
Authorization: Bearer evr_…
Idempotency-Key: plan-2026-09-03-a
{
"asset_id": "8f1c2e2a-…-2b3c4d5e6f70",
"intervals": [
{ "start": "2026-09-03T10:00:00Z",
"end": "2026-09-03T11:00:00Z",
"service": "discharge", "power_kw": 20 },
{ "start": "2026-09-03T11:00:00Z",
"end": "2026-09-03T12:00:00Z",
"service": "charge", "power_kw": -30,
"target_soc_pct": 80 }
]
}
  1. 1Any string unique to this plan. Send the request twice and you get the same schedule back, not two.
  2. 2Kilowatts. Positive discharges the battery, negative charges it. Market services carry capacity_kw instead.
  3. 3Optional. The edge stops the interval early once the battery reaches this state of charge.
Evora answers, then the edge confirms
202 Accepted
{
"schedule_id": "7a943ccb-…-e644bd50003d",
"schedule_version": 12,
"status": "dispatched",
"n_events": 4,
"replayed": false
}
… 5 s later, GET /schedules/7a943ccb-…
"status": "acknowledged",
"acknowledged_at": "2026-09-03T09:58:46Z"
  1. 4Evora numbers plans per asset. You never manage versions.
  2. 5Sent to the edge, not yet confirmed.
  3. 6The edge reported that it holds this plan. It becomes active when the first interval starts and completed after the last one ends.

How it works

Five steps, two parties. The owner keeps the limits; you keep the strategy.

  1. Step 1

    The owner invites you, then grants a lease

    You get your own Evora tenant and create an API key under Settings. The lease says which services you may run, a power cap, a time window. One optimizer per asset at a time.

  2. Step 2

    You read the asset

    Power, state of charge, the owner's safety limits and how much the battery can give in each direction right now.

  3. Step 3

    You send a schedule

    A list of intervals. Dry-run it first if you like. Evora dispatches it to the edge and gives you an id.

  4. Step 4

    The edge runs it inside the limits

    It confirms the plan, applies each interval, and clamps anything beyond the owner's envelope.

  5. Step 5

    You get the settlement

    After every interval: what you asked for, what was delivered, and why any difference happened. On the API and on your webhook.

Your first schedule in five calls

Everything below runs against the sandbox too. Replace the key and the ids.

  1. Call 1

    See what you hold

    Leases are granted by the asset owner in the Evora app. This tells you which assets, on what terms, and until when.

    Look for: Each lease has a state. Only active ones can be controlled now. service_scope and max_power_kw are your terms.

    curl https://api.evora-labs.com/api/v1/ems/assignments \
    -H 'Authorization: Bearer evr_…'
  2. Call 2

    Read the asset

    Plan inside the owner's limits and the battery's current headroom instead of guessing.

    Look for: limits is the safety envelope the edge enforces. capability is what the battery can deliver up and down right now. soc_pct is the state of charge.

    curl https://api.evora-labs.com/api/v1/ems/assets/{asset_id}/state \
    -H 'Authorization: Bearer evr_…'
  3. Call 3

    Check the plan without dispatching

    The dry run runs every check a real submission would and persists nothing.

    Look for: A 200 with valid: true, or the exact 403, 409 or 422 you would have received, with the offending intervals listed.

    curl 'https://api.evora-labs.com/api/v1/ems/schedules?validate_only=true' \
    -H 'Authorization: Bearer evr_…' \
    -H 'Content-Type: application/json' \
    -d @plan.json
  4. Call 4

    Send it, safely retryable

    Networks fail mid-request. The key makes a retry return the same schedule instead of creating a second one.

    Look for: A 202 with schedule_id. On a retry, replayed: true and the same id.

    curl https://api.evora-labs.com/api/v1/ems/schedules \
    -H 'Authorization: Bearer evr_…' \
    -H 'Idempotency-Key: plan-2026-09-03-a' \
    -H 'Content-Type: application/json' \
    -d @plan.json
  5. Call 5

    Watch it land

    Acknowledgement is the edge itself reporting that it holds your plan, not Evora guessing.

    Look for: status goes dispatched, then acknowledged within seconds, active when the first interval starts. If nothing confirms within 60 seconds it reads undelivered until the edge catches up.

    curl https://api.evora-labs.com/api/v1/ems/schedules/{schedule_id} \
    -H 'Authorization: Bearer evr_…'

What a schedule goes through

The status is the edge speaking, not Evora guessing. Four stops on the main line, three side exits.

  1. 1
    dispatched

    sent to the edge

  2. 2
    acknowledged

    the edge confirmed it holds the plan

  3. 3
    active

    the first interval has started

  4. 4
    completed

    the last interval has ended

Side exits

undelivered
No confirmation within 60 seconds, usually an edge that is offline. The plan is kept and delivered when the edge returns; the status flips to acknowledged by itself.
superseded
A newer plan replaced this one, yours or the owner's. If it was the owner, you receive schedule.superseded saying so.
expired
The lease ended, or the window passed without the edge ever running the plan.

What you get back

After every interval, one row that says what you asked for, what was delivered, and why any difference happened.

The settlement row, about 15 s after an interval ends
GET /schedules/7a943ccb-…/deviations
{
"intervals": [{
"index": 0, "service": "discharge",
"requested_kwh": 20.0,
"delivered_kwh": 18.4,
"energy_error_pct": 8.0,
"clamped_pct": 0.0,
"coverage_pct": 98.6,
"deviation": true,
"reasons": ["energy_error"]
}],
"thresholds": { "energy_error_pct": 5,
"clamped_pct": 10, "min_coverage_pct": 50 }
}
  1. 1What you asked for and what the battery actually delivered, in kWh. For market services only the delivered side exists.
  2. 2How much of the interval the edge held the battery below your request because of the owner's limits.
  3. 3How much of the interval the edge was reporting and running this plan. Low coverage means the plan was not executed.
  4. 4Flagged when the energy error is above 5 percent, clamping above 10 percent, or coverage below 50 percent. reasons says which.
The same row, pushed to your webhook
POST https://your-endpoint.example/evora
webhook-id: msg_3f1… webhook-signature: v1,…
{
"type": "schedule.deviation",
"data": {
"asset_id": "8f1c2e2a-…",
"schedule_id": "7a943ccb-…",
"interval": { "index": 0, "deviation": true,
"reasons": ["energy_error"], … }
}
}
  1. 5Signed per the Standard Webhooks spec with the secret you received when you registered the endpoint. Retried on failure.
  2. 6Every interval sends schedule.interval.completed. Flagged ones also send schedule.deviation. Lease changes send lease.started, lease.ended and schedule.superseded.

When a plan is refused

Every refusal carries a stable code. Match on the code, not the message.

403

Outside your lease

The plan asks for something the owner did not grant. Each offending interval is listed with a code.

service_out_of_scopepower_exceeds_leasecapacity_exceeds_lease

409

Someone else holds the asset

Another optimizer's lease is in force for that window. The response names them and says until when.

holder.tenant_nameholder.valid_to

422

The plan itself is invalid

Something about the intervals cannot be run. Fix the listed intervals and resend.

all_intervals_pastinterval_too_shortbeyond_horizoncapacity_infeasible

429

Too many submissions

More than 120 schedule submissions in a minute on one key. Wait and retry with the same Idempotency-Key.

Market data, no key

The prices behind the free market pages, as JSON. No account, no token, 15-minute cache.

Everything on evora-labs.com/market is served by a handful of JSON routes on the same site, and they take no key. Point a Home Assistant script, a notebook or a bidding tool at them. Responses are cached for 15 minutes; day-ahead data is immutable once published, so that costs you nothing. Windows are capped at 400 days per request.

Zone codes are the bidding zones (SE1SE4, FI, NO1NO5, DK1, DK2, RO). Swedish FCR is a national product: ask for zone SE. Timestamps are UTC, ISO 8601. Capacity prices are EUR per MW per hour; energy prices (mFRR-EAM, imbalance, Finnish aFRR energy) are EUR per MWh.

Fair use: this is a free service for people, not for mirroring. If you need bulk history or a guarantee, talk to us.

Three calls
# Day-ahead, SE3, last 7 days, hourly
curl "https://evora-labs.com/api/market/spot?zones=SE3\
&start=2026-08-27T00:00:00Z&end=2026-09-05T00:00:00Z&granularity=hourly"
# FCR-D up, Sweden, D-2 auction only, daily averages
curl "https://evora-labs.com/api/market/ancillary?products=FCR-D&zones=SE\
&directions=up&auctions=u1&start=2026-06-01T00:00:00Z&end=2026-09-01T00:00:00Z&granularity=daily"
# Negative-hours league table
curl "https://evora-labs.com/api/market/stats/negative-hours"
RouteReturnsParameters
GET /api/market/spotDay-ahead prices per zone, EUR + local currencyzones, start, end, granularity (auto|15min|hourly|daily|monthly)
GET /api/market/ancillaryReserve prices: FCR-N, FCR-D, aFRR, mFRR-CM, mFRR-EAM, FFR, FCR (RO), imbalance (RO)products, zones, start, end, granularity, directions (up,down), auctions (u1,u2), avg_auction, price_type (capacity,energy)
GET /api/market/ancillary/summaryAverage and standard deviation per product, direction and zonezones, start, end
GET /api/market/stats/spreadsDaily top-bottom spreads per zone (1-hour and 4-hour), plus period averageszones, start, end
GET /api/market/stats/recordsAll-time highs and lows with dates and the top-three historyzones (optional)
GET /api/market/stats/negative-hoursNegative-price hours per zone, year to date vs last year, cumulative curveszones (optional)
GET /api/market/stats/headlinesLast complete month in one sentence per series, with ranks and deltaszones, products (optional)
GET /api/market/export/spot.csv · …/ancillary.csvThe same series as CSVas the JSON routes

Questions people ask

How do I get access?

The asset owner invites your company from the asset's Control tab in the Evora app. You accept the invite, sign in, and create an API key under Settings → API keys. The owner then grants you a lease; you see it on your Leases page and on GET /assignments.

Who sets the power and state-of-charge limits?

The asset owner, on the edge. They are enforced on every command regardless of who sent it, and you can read them on the state endpoint. A plan beyond them is delivered at the limit, and the settlement row reports the clamping.

What happens if the edge is offline when I send a plan?

The plan is kept on the message broker and fetched by the edge when it reconnects. Meanwhile the schedule reads undelivered. Once the edge confirms, the status moves on by itself.

Can two optimizers share one asset?

No. A lease is exclusive for its window. The owner can grant you a lease that starts when another ends, and you can see who holds an asset you have a lease record on.

Can the owner interrupt my plan?

Yes, always. An owner dispatch supersedes your plan and you are told with a schedule.superseded event that names the actor. Emergency stops are never blocked by a lease.

How fast is the real-time aFRR path?

Cloud to edge to telemetry echo measures in seconds in our test suite. It is best effort without an SLA, and only available while an aFRR interval is committed and your lease allows aFRR.

Can I read the battery's own registers?

Yes, with a lease in force. The registers endpoint lists every register the device driver exposes, with address, scaling and unit, and the values endpoint reads the ones you name on demand through the edge. Writes are limited to registers the owner lists on your lease; power setpoints, arming switches and the watchdog are never writable from outside.

How do I test before I have a real asset?

Lease the sandbox: a simulated battery behind a real Evora edge. Every route works on it, including acknowledgement, settlement rows and webhooks.

Ready to send your first plan?

Ask the asset owner to invite you, create your key under Settings, and lease the sandbox while you wait for a real asset.

Open the reference