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.
POST /api/v1/ems/schedulesAuthorization: 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 }]}
- 1Any string unique to this plan. Send the request twice and you get the same schedule back, not two.
- 2Kilowatts. Positive discharges the battery, negative charges it. Market services carry
capacity_kwinstead. - 3Optional. The edge stops the interval early once the battery reaches this state of charge.
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"
- 4Evora numbers plans per asset. You never manage versions.
- 5Sent to the edge, not yet confirmed.
- 6The edge reported that it holds this plan. It becomes
activewhen the first interval starts andcompletedafter the last one ends.
How it works
Five steps, two parties. The owner keeps the limits; you keep the strategy.
- 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.
- 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.
- 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.
- Step 4
The edge runs it inside the limits
It confirms the plan, applies each interval, and clamps anything beyond the owner's envelope.
- 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.
- 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. Onlyactiveones can be controlled now.service_scopeandmax_power_kware your terms.curl https://api.evora-labs.com/api/v1/ems/assignments \-H 'Authorization: Bearer evr_…' - Call 2
Read the asset
Plan inside the owner's limits and the battery's current headroom instead of guessing.
Look for:
limitsis the safety envelope the edge enforces.capabilityis what the battery can deliver up and down right now.soc_pctis the state of charge.curl https://api.evora-labs.com/api/v1/ems/assets/{asset_id}/state \-H 'Authorization: Bearer evr_…' - 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 - 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: trueand 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 - Call 5
Watch it land
Acknowledgement is the edge itself reporting that it holds your plan, not Evora guessing.
Look for:
statusgoesdispatched, thenacknowledgedwithin seconds,activewhen the first interval starts. If nothing confirms within 60 seconds it readsundelivereduntil 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.
- 1dispatched
sent to the edge
- 2acknowledged
the edge confirmed it holds the plan
- 3active
the first interval has started
- 4completed
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.
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 }}
- 1What you asked for and what the battery actually delivered, in kWh. For market services only the delivered side exists.
- 2How much of the interval the edge held the battery below your request because of the owner's limits.
- 3How much of the interval the edge was reporting and running this plan. Low coverage means the plan was not executed.
- 4Flagged when the energy error is above 5 percent, clamping above 10 percent, or coverage below 50 percent.
reasonssays which.
POST https://your-endpoint.example/evorawebhook-id: msg_3f1… webhook-signature: v1,…{"type": "schedule.deviation","data": {"asset_id": "8f1c2e2a-…","schedule_id": "7a943ccb-…","interval": { "index": 0, "deviation": true,"reasons": ["energy_error"], … }}}
- 5Signed per the Standard Webhooks spec with the secret you received when you registered the endpoint. Retried on failure.
- 6Every interval sends
schedule.interval.completed. Flagged ones also sendschedule.deviation. Lease changes sendlease.started,lease.endedandschedule.superseded.
When a plan is refused
Every refusal carries a stable code. Match on the code, not the message.
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
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
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
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 (SE1–SE4, FI, NO1–NO5, 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.
# Day-ahead, SE3, last 7 days, hourlycurl "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 averagescurl "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 tablecurl "https://evora-labs.com/api/market/stats/negative-hours"
| Route | Returns | Parameters |
|---|---|---|
| GET /api/market/spot | Day-ahead prices per zone, EUR + local currency | zones, start, end, granularity (auto|15min|hourly|daily|monthly) |
| GET /api/market/ancillary | Reserve 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/summary | Average and standard deviation per product, direction and zone | zones, start, end |
| GET /api/market/stats/spreads | Daily top-bottom spreads per zone (1-hour and 4-hour), plus period averages | zones, start, end |
| GET /api/market/stats/records | All-time highs and lows with dates and the top-three history | zones (optional) |
| GET /api/market/stats/negative-hours | Negative-price hours per zone, year to date vs last year, cumulative curves | zones (optional) |
| GET /api/market/stats/headlines | Last complete month in one sentence per series, with ranks and deltas | zones, products (optional) |
| GET /api/market/export/spot.csv · …/ancillary.csv | The same series as CSV | as 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.