Metis Events API
Connect an event-management, catering or CRM system to Metis Room Planner. Your software asks for a floorplan; the venue's own team draws it properly in Metis; you get the finished plan back as a PDF to attach to the BEO or send to the customer.
What this API is for
Venues already run their events in your software. What they don't have is a quick way to turn a booking into a room plan without re-typing everything into a separate drawing tool. That's the gap this API closes.
The unit of work is the plan request. You send a room, a layout style, guest numbers and the event details. Metis creates the plan in the venue's account and puts it in front of their team, who know where the pillar is and which door the band loads in through. When they mark it complete, the plan is rendered to a PDF and a PNG that you can pull straight back into the booking.
Everything else — listing venues and rooms, keeping occasions in step, opening Metis for a person — exists to support that loop.
Rooms are structure, not furniture. A Metis room holds walls, doors, windows, pillars and fixed features, with no tables or chairs. Furniture belongs to the event, so every plan starts from the empty room and is laid out for the numbers on the night.
The main flow
- Create the occasion
POST /v1/occasions— the event, its date, type and guest count. - Pick a room
GET /v1/venuesthenGET /v1/venues/{venueId}/rooms. Cache these; they change rarely. - Request the plan
POST /v1/occasions/{occasionId}/plan-requestswith the room, layout style, numbers and times. Status:requested. - The venue does the work
Their team opens the request in Metis, finishes the plan and marks it complete.
Status:
in_progress→completed. - Collect the result
Poll
GET /v1/plan-requests?status=completed, then take the newest entry inversionsand download the PDF — or store its signedpdfShareUrl. - Changes happen
PATCHthe plan request when numbers or times move. Metis shows the venue's team exactly what changed and the request goes back tochanges_requested, producing a new version when they finish.
Getting access
There are two kinds of credential, and an integration usually ends up holding both.
- A partner key, issued by Metis. Contact Metis to be set up as an integration partner. You get a key for testing and, once you're ready, one for production.
- A key the customer issues to you. The venue's own owner or administrator generates a key for your integration from the Integrations screen inside Metis Room Planner, names it after you, and can revoke it at any moment without affecting anyone else. This is the credential your customer pastes into your settings screen.
Either way the key is shown once, when it is created. Metis stores only a hash of it and cannot show it to you again — if it's lost, the customer revokes it and issues a new one.
Authentication
Every call carries your key as a bearer token over HTTPS:
Authorization: Bearer mk_live_YOUR_KEY_HERE
Keys beginning mk_test_ act on test data; mk_live_ keys act on the real account.
After the prefix come 40 random characters — never parse them, and never derive anything from them.
A key belongs to a connection: one venue business's Metis account. Most keys are pinned to a single connection and you can ignore this entirely. If Metis has given you a key that serves several accounts, name the one you mean in a header:
Metis-Connection: con_8fK2mQpL4xRz9TbW3vNcYh7J
Each key is granted only the scopes it needs. A call outside them returns
403 insufficient_scope — it is not a bug to fix by retrying, it means the customer needs to
grant more when issuing the key.
| Scope | Lets you |
|---|---|
venues:read | List venues, rooms and layout styles. |
occasions:read | Read occasions. |
occasions:write | Create and update occasions. |
floorplans:read | Read floorplans and plan requests, and download PDFs and PNGs. |
floorplans:write | Raise, change and cancel plan requests. |
links:create | Create links that open Metis for a person. |
GET /v1/connection tells you which account a key acts on and which scopes it holds. It's the
right call behind a "Test connection" button.
Keeping keys safe
An API key is a password to a venue's data. Treat it as one.
- Server-side only. Never put a key in browser JavaScript, a mobile app, a desktop binary or anything else a customer can read. If your front end needs data from Metis, proxy it through your own backend.
- Never in source control, log files, error reports, screenshots or support tickets. Load it from a secret store or an environment variable.
- One key per customer per integration. Don't share a single key across accounts — the point of per-integrator keys is that one can be revoked without disturbing the rest.
- Rotate on staff changes and on any suspicion. The customer can issue a new key and revoke the old one in seconds; revocation takes effect immediately.
- Handle 401 properly. If a key stops working, stop retrying it and tell your user to check it in Metis. Hammering a rejected key will get your IP rate-limited.
- Don't pass share links off as private. A
pdfShareUrlworks without a key for anyone who has it, for 30 days. That's the point, but treat it accordingly.
Quick start
Set two environment variables and check the key works:
export METIS_BASE="https://metisroomplanner.com/MetisEventsApi"
export METIS_API_KEY="mk_test_…" # from Metis, or from your customer's Integrations screen
export METIS_CONNECTION="con_…" # only if your key is not pinned to one account
curl -sS "$METIS_BASE/v1/connection" -H "Authorization: Bearer $METIS_API_KEY"
The samples further down assume one small helper. In Node:
const BASE = process.env.METIS_BASE;
async function metisRaw(path, { method = "GET", body, idempotencyKey, ifMatch } = {}) {
const headers = {
"Authorization": `Bearer ${process.env.METIS_API_KEY}`,
"Metis-Connection": process.env.METIS_CONNECTION,
};
if (body) headers["Content-Type"] = method === "PATCH"
? "application/merge-patch+json"
: "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
if (ifMatch) headers["If-Match"] = ifMatch;
return fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
}
async function metis(path, options) {
const response = await metisRaw(path, options);
if (!response.ok) {
// Errors are RFC 9457 problem documents. Branch on `code`, never on the message.
const problem = await response.json();
throw Object.assign(new Error(problem.title), problem, { status: response.status });
}
return response.status === 204 ? null : response.json();
}
And in C#:
var http = new HttpClient { BaseAddress = new Uri(Environment.GetEnvironmentVariable("METIS_BASE") + "/") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("METIS_API_KEY"));
http.DefaultRequestHeaders.Add("Metis-Connection", Environment.GetEnvironmentVariable("METIS_CONNECTION"));
Requests and responses
- Base URL
https://metisroomplanner.com/MetisEventsApi. HTTPS only; plain HTTP is redirected and then refused. - JSON in and out, UTF-8,
camelCaseproperty names. - Identifiers are opaque prefixed strings —
ven_8,room_26,occ_42,fp_118,pr_12,lay_58,con_…. Store them whole. Don't parse them, don't assume they're numeric, don't assume a length. - Timestamps are RFC 3339 in UTC (
2026-09-16T10:44:07Z). Calendar dates areYYYY-MM-DDand times of day areHH:mmin the venue's local time. - Ignore properties you don't recognise. New ones are added within
v1and that is not a breaking change. - Every response carries
X-Request-Id, echoed if you send your own. Log it. It's the fastest way for support to find your call.
Pagination
Lists take limit (1–100, default 25) and an opaque cursor. Follow
nextCursor until hasMore is false; the same next page is also given
as an RFC 8288 Link: <…>; rel="next" header. Never build a cursor yourself — an invalid
one returns 400 invalid_cursor.
Idempotency
Send Idempotency-Key: <uuid> on every POST. If the connection drops before
you see the response, retry with the same key: Metis returns the original response and adds
Idempotent-Replayed: true rather than doing the work twice. Keys are remembered for 24 hours.
Reusing a key with a different body returns 409 idempotency_key_reused — that's a bug
in your key generation, not a transient failure. Generate one key per logical operation and keep it with
the retry.
ETags and updates
Single resources come back with an ETag. Send it as If-None-Match on a read to
get a cheap 304 Not Modified, or as If-Match on a PATCH so your
update is rejected with 412 precondition_failed if someone changed the resource first. On a
412, re-read, re-apply your change and retry.
Updates are JSON Merge Patch (RFC 7396): send only the fields you're changing. null clears a
field that is allowed to be empty.
Rate limits
Authenticated partners get 600 requests per 60 seconds, shared across all of that
partner's keys and connections. Requests without a valid key are limited far more tightly, per IP address.
Over the limit you get 429 rate_limited with a Retry-After header — wait that
long, then retry with exponential backoff and jitter.
Don't poll harder than you need to. Once every few minutes per connection is plenty for
GET /v1/plan-requests?status=completed; a venue takes minutes or hours to finish a plan, not
seconds.
Errors
Errors are RFC 9457 problem documents (application/problem+json) with a stable, machine-readable
code. Branch on code, never on title — titles are
written for humans and may be reworded.
{
"type": "https://metisroomplanner.com/docs/events-api/errors#validation_failed",
"title": "One or more request parameters are invalid.",
"status": 400,
"code": "validation_failed",
"errors": { "guestCount": ["Must be between 0 and 100000."] },
"traceId": "3579c94390d9066755d9d544c9cbccd8"
}
| Status | Code | What to do |
|---|---|---|
| 400 | validation_failed | Read errors; it's keyed by field name. Fix and resend. |
| 400 | invalid_cursor | Start the list again from the first page. |
| 400 | connection_required | Your key serves several accounts — send Metis-Connection. |
| 401 | invalid_api_key | Stop. Ask your user to re-issue the key in Metis. |
| 403 | insufficient_scope | The key wasn't granted this scope. Ask for a key that has it. |
| 403 | connection_not_found | The connection is unknown or revoked. |
| 404 | resource_not_found | Also returned for resources that belong to another account. |
| 409 | duplicate_external_reference | You already created this. Fetch it instead. |
| 409 | idempotency_key_reused | Same key, different body. Fix your key generation. |
| 409 | idempotency_request_in_progress | The first attempt is still running. Wait and retry. |
| 409 | render_pending | The PDF isn't ready. Retry after Retry-After. |
| 412 | precondition_failed | Re-read, re-apply, retry. |
| 415 | unsupported_media_type | Set Content-Type correctly. |
| 422 | plan_allowance_reached | The venue's subscription is out of plans. Retrying won't help — tell your user. |
| 422 | subscription_inactive | The venue's Metis subscription isn't active. |
| 429 | rate_limited | Back off for Retry-After seconds. |
| 500 | internal_error | Safe to retry an idempotent request with backoff. Quote the traceId. |
| 503 | planner_unavailable | Retry with the same Idempotency-Key. |
Connection
Check your credentials
GET/v1/connectionno scope needed
Returns the venue account your key is acting on and the scopes it was granted. Call it once at start-up, or whenever a customer pastes a new key into your settings screen, to prove the key works before you try anything else. It needs no scope of its own.
Returns
A connection object.
Example response
{
"id": "con_8fK2mQpL4xRz9TbW3vNcYh7J",
"partner": {
"name": "Caterease"
},
"account": {
"name": "Grand Hotel Group"
},
"scopes": [
"venues:read",
"occasions:read",
"occasions:write",
"floorplans:read",
"floorplans:write",
"links:create"
]
}
Notes
- This is the call to use in a 'Test connection' button.
Example request
curl -sS "$METIS_BASE/v1/connection" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/connection");
console.log(result);using var response = await http.GetAsync("v1/connection");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Venues and rooms
List venues
GET/v1/venuesscope venues:read
The venues on the connected account — the buildings or sites the customer runs events in. Use it to populate a venue picker. Rooms live underneath a venue.
Query parameters
| Name | Type | Description |
|---|---|---|
query | string optional | Case-insensitive substring match on the venue or room name. |
limit | integer optional default 25 | How many items to return, 1–100. |
cursor | string optional | The nextCursor from the previous page. Opaque — pass it back unchanged. |
Returns
A page of venues, each with the number of rooms it has.
Example response
{
"data": [
{
"id": "ven_8",
"name": "Grand Hotel",
"roomCount": 6
}
],
"hasMore": false,
"nextCursor": null
}
Example request
curl -sS "$METIS_BASE/v1/venues?limit=25" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/venues");
console.log(result);using var response = await http.GetAsync("v1/venues");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Retrieve a venue
GET/v1/venues/{venueId}scope venues:read
One venue by id. Useful when you store the venue id against your own site record and want to show its current name.
Path parameters
| Name | Type | Description |
|---|---|---|
venueId | string required | A venue id from GET /v1/venues, e.g. ven_8. |
Returns
A venue object.
Example response
{
"id": "ven_8",
"name": "Grand Hotel",
"roomCount": 6
}
Example request
curl -sS "$METIS_BASE/v1/venues/ven_8" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/venues/ven_8");
console.log(result);using var response = await http.GetAsync("v1/venues/ven_8");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();List a venue's rooms
GET/v1/venues/{venueId}/roomsscope venues:read
Every room at a venue, with its dimensions and any layouts the customer has already saved for it. A room is structure only — walls, doors, windows, pillars and fixed features, with no furniture; furniture is laid out per event. This is the call that feeds your room picker, and the room id is what you send when you ask for a plan.
Path parameters
| Name | Type | Description |
|---|---|---|
venueId | string required | A venue id from GET /v1/venues. |
Returns
All of the venue's rooms in a single page.
Example response
{
"data": [
{
"id": "room_26",
"venueId": "ven_8",
"name": "Ballroom",
"structureVersion": 3,
"dimensions": {
"widthCm": 1800,
"lengthCm": 2400,
"ceilingHeightCm": 420
},
"layouts": [
{
"id": "lay_58",
"name": "Cabaret Banquet, Dancefloor & Disco",
"updatedAt": "2026-08-30T14:02:11Z"
}
]
}
],
"hasMore": false,
"nextCursor": null
}
Notes
structureVersiontells you which published version of the room a plan was built from. It changes when the venue re-measures or re-draws the room.
Example request
curl -sS "$METIS_BASE/v1/venues/ven_8/rooms" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/venues/ven_8/rooms");
console.log(result);using var response = await http.GetAsync("v1/venues/ven_8/rooms");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Retrieve a room
GET/v1/rooms/{roomId}scope venues:read
One room by id, with the same detail as the list above. Handy for refreshing a room you have stored without re-reading the whole venue.
Path parameters
| Name | Type | Description |
|---|---|---|
roomId | string required | A room id, e.g. room_26. |
Returns
A room object.
Example response
{
"id": "room_26",
"venueId": "ven_8",
"name": "Ballroom",
"structureVersion": 3,
"dimensions": {
"widthCm": 1800,
"lengthCm": 2400,
"ceilingHeightCm": 420
},
"layouts": []
}
Example request
curl -sS "$METIS_BASE/v1/rooms/room_26" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/rooms/room_26");
console.log(result);using var response = await http.GetAsync("v1/rooms/room_26");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Occasions
List occasions
GET/v1/occasionsscope occasions:read
The events held at the customer's venues that your system has told Metis about, earliest date first. Use it to reconcile after an outage, or to show what Metis already knows.
Query parameters
| Name | Type | Description |
|---|---|---|
year | integer optional | Only occasions whose date falls in this calendar year. |
limit | integer optional default 25 | How many items to return, 1–100. |
cursor | string optional | The nextCursor from the previous page. Opaque — pass it back unchanged. |
Returns
A page of occasions ordered by date.
Example response
{
"data": [
{
"id": "occ_42",
"name": "Smith-Jones Wedding",
"date": "2027-06-12",
"type": "wedding",
"guestCount": 150,
"venueId": "ven_8",
"externalReference": "CRM-10492",
"createdBy": {
"name": "Alex Planner",
"email": "[email protected]"
},
"createdAt": "2026-09-17T08:34:06Z",
"updatedAt": "2026-09-17T08:34:06Z"
}
],
"hasMore": false,
"nextCursor": null
}
Example request
curl -sS "$METIS_BASE/v1/occasions?year=2027&limit=25" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/occasions");
console.log(result);using var response = await http.GetAsync("v1/occasions");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Create an occasion
POST/v1/occasionsscope occasions:write
Tells Metis about an event in your system — the wedding, conference or dinner the plan will be for. This is normally the first call you make for a booking; everything else (plan requests, floorplans) hangs off the occasion it returns. Put your own booking reference in externalReference so you can find it again.
Headers
| Header | Type | Description |
|---|---|---|
Idempotency-Key | string optional | A unique value per logical operation (a UUID is ideal). Retrying with the same key within 24 hours returns the original response instead of doing the work twice. Strongly recommended for every POST. |
Body fields
| Field | Type | Description |
|---|---|---|
name | string required | What the event is called, as your customer would recognise it. 1–300 characters. |
date | date required | The event date, YYYY-MM-DD. |
type | enum required | One of wedding, corporate, not_for_profit, bar_bat_mitzvah, dinner_party, other. |
guestCount | integer required | Expected number of guests, 0–100000. |
venueId | string optional | Which venue it's at. Optional, but set it if you know — it filters the rooms the venue's team sees. |
externalReference | string optional | Your identifier for this event (booking number, CRM id). Up to 200 characters, and unique among your connection's occasions. |
createdBy | object optional | Who raised it in your system: { name, email }. Shown to the venue's team so they know who to ask. |
Returns
201 Created with the occasion, a Location header and an ETag.
Errors worth handling
409 duplicate_external_reference | You've already created an occasion with that reference. Fetch it instead of creating another. |
Notes
- Retrying with the same
Idempotency-Keyreturns the original occasion and the headerIdempotent-Replayed: true, so a network timeout never creates a duplicate.
Example request
curl -sS -X POST "$METIS_BASE/v1/occasions" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"name": "Smith-Jones Wedding", "date": "2027-06-12", "type": "wedding", "guestCount": 150, "venueId": "ven_8", "externalReference": "CRM-10492", "createdBy": {"name": "Alex Planner", "email": "[email protected]"}}'const created = await metis("/v1/occasions", { method: "POST", body: {
"name": "Smith-Jones Wedding",
"date": "2027-06-12",
"type": "wedding",
"guestCount": 150,
"venueId": "ven_8",
"externalReference": "CRM-10492",
"createdBy": {
"name": "Alex Planner",
"email": "[email protected]"
}
}, idempotencyKey: crypto.randomUUID() });
console.log(created);var payload = JsonSerializer.Deserialize<JsonElement>("""
{
"name": "Smith-Jones Wedding",
"date": "2027-06-12",
"type": "wedding",
"guestCount": 150,
"venueId": "ven_8",
"externalReference": "CRM-10492",
"createdBy": {
"name": "Alex Planner",
"email": "[email protected]"
}
}
""");
using var content = JsonContent.Create(payload, new MediaTypeHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/occasions") { Content = content };
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Retrieve an occasion
GET/v1/occasions/{occasionId}scope occasions:read
One occasion by id, with an ETag you'll need if you want to update it safely.
Path parameters
| Name | Type | Description |
|---|---|---|
occasionId | string required | An occasion id, e.g. occ_42. |
Returns
An occasion object.
Example response
{
"id": "occ_42",
"name": "Smith-Jones Wedding",
"date": "2027-06-12",
"type": "wedding",
"guestCount": 150,
"venueId": "ven_8",
"externalReference": "CRM-10492",
"createdBy": {
"name": "Alex Planner",
"email": "[email protected]"
},
"createdAt": "2026-09-17T08:34:06Z",
"updatedAt": "2026-09-17T08:34:06Z"
}
Notes
- Send
If-None-Match: "<etag>"to get a cheap304 Not Modifiedwhen nothing has changed.
Example request
curl -sS "$METIS_BASE/v1/occasions/occ_42" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/occasions/occ_42");
console.log(result);using var response = await http.GetAsync("v1/occasions/occ_42");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Update an occasion
PATCH/v1/occasions/{occasionId}scope occasions:write
Changes an event Metis already knows about — the date moved, the numbers went up, it switched venue. Send only the fields that changed (JSON Merge Patch, RFC 7396).
Path parameters
| Name | Type | Description |
|---|---|---|
occasionId | string required | An occasion id. |
Headers
| Header | Type | Description |
|---|---|---|
If-Match | string optional | The ETag you last read. The update fails with 412 if someone changed the resource in the meantime. |
Body fields
| Field | Type | Description |
|---|---|---|
name | string optional | 1–300 characters. Cannot be cleared. |
date | date optional | YYYY-MM-DD. Cannot be cleared. |
type | enum optional | As on create. Cannot be cleared. |
guestCount | integer optional | 0–100000. Cannot be cleared. |
venueId | string optional | Send null to clear it. |
externalReference | string optional | Send null to clear it. |
Returns
The updated occasion with a new ETag.
Errors worth handling
412 precondition_failed | Someone changed the occasion after the ETag you sent. Re-read it, re-apply your change and try again. |
Notes
- The request
Content-Typemay beapplication/merge-patch+jsonor plainapplication/json. - Omitting
If-Matchis allowed — it just means last write wins.
Example request
curl -sS -X PATCH "$METIS_BASE/v1/occasions/occ_42" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-H 'If-Match: "the-etag-you-last-read"' \
-H "Content-Type: application/merge-patch+json" \
-d '{"guestCount": 165, "date": "2027-06-19"}'const updated = await metis("/v1/occasions/occ_42", { method: "PATCH", body: {
"guestCount": 165,
"date": "2027-06-19"
}, ifMatch: occasion.etag });
console.log(updated);var payload = JsonSerializer.Deserialize<JsonElement>("""
{
"guestCount": 165,
"date": "2027-06-19"
}
""");
using var content = JsonContent.Create(payload, new MediaTypeHeaderValue("application/merge-patch+json"));
using var request = new HttpRequestMessage(HttpMethod.Patch, "v1/occasions/occ_42") { Content = content };
request.Headers.Add("If-Match", etag); // the ETag you last read
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Plan requests
List layout styles
GET/v1/layout-stylesscope venues:read
The room layouts Metis can generate — banquet rounds, cabaret, theatre, classroom and so on — with the options each one takes and the extras (top table, dance floor, stage) it supports. Read this to build your layout picker instead of hard-coding the list, because styles get added.
Returns
Every layout style in one page.
Example response
{
"data": [
{
"key": "banquet",
"name": "Banquet (round tables)",
"generatesFurniture": true,
"options": [
{
"name": "tableSize",
"type": "integer",
"allowed": [
8,
10,
12
],
"default": 10
}
],
"extras": [
"topTable",
"danceFloor",
"stage"
]
}
],
"hasMore": false,
"nextCursor": null
}
Notes
generatesFurniture: false(e.g. reception) means Metis prepares the room but the venue's team places the furniture by hand.
Example request
curl -sS "$METIS_BASE/v1/layout-styles" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/layout-styles");
console.log(result);using var response = await http.GetAsync("v1/layout-styles");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Request a floorplan
POST/v1/occasions/{occasionId}/plan-requestsscope floorplans:write
The heart of the integration. You send the room, the layout style and the numbers; Metis creates the plan in the venue's account and puts it on their to-do list. Someone at the venue opens it, finishes it properly and marks it complete — and you get back a PDF and a PNG to attach to your BEO or customer proposal. Leave layoutStyle out and Metis just opens the empty room for them.
Path parameters
| Name | Type | Description |
|---|---|---|
occasionId | string required | The occasion this plan is for. |
Headers
| Header | Type | Description |
|---|---|---|
Idempotency-Key | string optional | A unique value per logical operation (a UUID is ideal). Retrying with the same key within 24 hours returns the original response instead of doing the work twice. Strongly recommended for every POST. |
Body fields
| Field | Type | Description |
|---|---|---|
roomId | string required | The room to plan, from GET /v1/venues/{venueId}/rooms. |
layoutStyle | enum optional | A key from GET /v1/layout-styles, or null for an empty room. |
layoutOptions | object optional | Options for that style, e.g. {"tableSize": 10, "danceFloor": true}. Values are whole numbers or true/false. |
guestCount | integer optional | Guests to lay out for. Defaults to the occasion's guest count. |
startTime | string optional | Venue local time, HH:mm. |
endTime | string optional | Venue local time, HH:mm. |
notes | string optional | Anything the venue's team should know. Up to 4000 characters — they read this. |
contact | object optional | Who to ask about it: { name, email, phone }. |
externalReference | string optional | Your reference, e.g. the BEO number. It's used in the download file names, so it's worth setting. |
Returns
201 Created with the plan request in status requested.
Errors worth handling
422 plan_allowance_reached | The venue's Metis subscription has no plans left this period. Tell your user to contact the venue — retrying won't help. |
422 subscription_inactive | The venue's Metis subscription isn't active. |
503 planner_unavailable | Metis couldn't be reached. Retry with the same Idempotency-Key after Retry-After seconds. |
Notes
- Each plan request uses one of the venue's plan allowance. Don't create speculative requests.
- Room structures themselves never count against the allowance — only the plans made from them.
Example request
curl -sS -X POST "$METIS_BASE/v1/occasions/occ_42/plan-requests" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"roomId": "room_26", "layoutStyle": "banquet", "layoutOptions": {"tableSize": 10, "danceFloor": true}, "guestCount": 120, "startTime": "19:00", "endTime": "23:30", "notes": "Top table for 12 on the stage side.", "contact": {"name": "Alex Planner", "email": "[email protected]", "phone": "+44 1234 567890"}, "externalReference": "BEO-7781"}'const created = await metis("/v1/occasions/occ_42/plan-requests", { method: "POST", body: {
"roomId": "room_26",
"layoutStyle": "banquet",
"layoutOptions": {
"tableSize": 10,
"danceFloor": true
},
"guestCount": 120,
"startTime": "19:00",
"endTime": "23:30",
"notes": "Top table for 12 on the stage side.",
"contact": {
"name": "Alex Planner",
"email": "[email protected]",
"phone": "+44 1234 567890"
},
"externalReference": "BEO-7781"
}, idempotencyKey: crypto.randomUUID() });
console.log(created);var payload = JsonSerializer.Deserialize<JsonElement>("""
{
"roomId": "room_26",
"layoutStyle": "banquet",
"layoutOptions": {
"tableSize": 10,
"danceFloor": true
},
"guestCount": 120,
"startTime": "19:00",
"endTime": "23:30",
"notes": "Top table for 12 on the stage side.",
"contact": {
"name": "Alex Planner",
"email": "[email protected]",
"phone": "+44 1234 567890"
},
"externalReference": "BEO-7781"
}
""");
using var content = JsonContent.Create(payload, new MediaTypeHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/occasions/occ_42/plan-requests") { Content = content };
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();List plan requests
GET/v1/plan-requestsscope floorplans:read
Your open and finished plan requests, oldest first. Poll this with ?status=completed to pick up plans the venue has finished — that's the simplest way to keep your side in step until webhooks arrive.
Query parameters
| Name | Type | Description |
|---|---|---|
status | enum optional | requested, in_progress, completed, changes_requested or cancelled. |
occasionId | string optional | Only requests for this occasion. |
limit | integer optional default 25 | How many items to return, 1–100. |
cursor | string optional | The nextCursor from the previous page. Opaque — pass it back unchanged. |
Returns
A page of plan requests. warnings is only filled in when you retrieve a single request.
Example response
{
"data": [
{
"id": "pr_12",
"occasionId": "occ_42",
"roomId": "room_26",
"status": "completed",
"currentVersion": 2,
"externalReference": "BEO-7781"
}
],
"hasMore": false,
"nextCursor": null
}
Notes
- Poll politely — once every few minutes per connection is plenty. See Rate limits.
Example request
curl -sS "$METIS_BASE/v1/plan-requests?status=completed&occasionId=occ_42" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/plan-requests");
console.log(result);using var response = await http.GetAsync("v1/plan-requests");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Retrieve a plan request
GET/v1/plan-requests/{planRequestId}scope floorplans:read
The full state of one request: where it's got to, what the venue's team has been told, any warnings raised while laying out the furniture, and every completed version with fresh download links. This is the call you make when your user opens the booking.
Path parameters
| Name | Type | Description |
|---|---|---|
planRequestId | string required | A plan request id, e.g. pr_12. |
Returns
The plan request, its versions and their links.
Example response
{
"id": "pr_12",
"occasionId": "occ_42",
"roomId": "room_26",
"floorplanId": "fp_118",
"status": "completed",
"statusChangedAt": "2026-09-17T15:12:44Z",
"layoutStyle": "banquet",
"layoutOptions": {
"tableSize": 10,
"danceFloor": true
},
"guestCount": 120,
"startTime": "19:00",
"endTime": "23:30",
"notes": "Top table for 12 on the stage side.",
"contact": {
"name": "Alex Planner",
"email": "[email protected]",
"phone": "+44 1234 567890"
},
"externalReference": "BEO-7781",
"warnings": [
{
"code": "capacity_shortfall",
"message": "Seated 110 of 120 guests; the venue's team will adjust."
}
],
"pendingChanges": [],
"currentVersion": 1,
"versions": [
{
"version": 1,
"completedAt": "2026-09-17T15:12:44Z",
"renderStatus": "rendered",
"renderedAt": "2026-09-17T15:13:02Z",
"pdfUrl": "https://metisroomplanner.com/MetisEventsApi/v1/plan-requests/pr_12/versions/1/pdf",
"pngUrl": "https://metisroomplanner.com/MetisEventsApi/v1/plan-requests/pr_12/versions/1/png",
"pdfShareUrl": "https://metisroomplanner.com/MetisEventsApi/files/pr_12/1/BEO-7781-floorplan-v1.pdf?e=1789..&s=6f21..",
"pngShareUrl": "https://metisroomplanner.com/MetisEventsApi/files/pr_12/1/BEO-7781-floorplan-v1.png?e=1789..&s=a03c.."
}
],
"plannerUrl": "https://metisroomplanner.com/metisroomplanner/planner/?plan=...",
"createdAt": "2026-09-17T09:02:10Z",
"updatedAt": "2026-09-17T15:13:02Z"
}
Notes
statusmovesrequested→in_progress→completed, and back tochanges_requestedif you change the brief afterwards.cancelledis final.currentVersionis0until the first time the venue completes the plan.- Share links expire after 30 days; retrieve the plan request again for fresh ones.
Example request
curl -sS "$METIS_BASE/v1/plan-requests/pr_12" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/plan-requests/pr_12");
console.log(result);using var response = await http.GetAsync("v1/plan-requests/pr_12");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Change a plan request
PATCH/v1/plan-requests/{planRequestId}scope floorplans:write
The numbers changed, the dance floor is out, the times moved. Send only what changed and Metis shows the venue's team exactly which fields differ from the plan they already drew, moving the request to changes_requested if they'd finished it. The room can't be changed — cancel and raise a new request instead.
Path parameters
| Name | Type | Description |
|---|---|---|
planRequestId | string required | A plan request id. |
Headers
| Header | Type | Description |
|---|---|---|
If-Match | string optional | The ETag you last read. The update fails with 412 if someone changed the resource in the meantime. |
Body fields
| Field | Type | Description |
|---|---|---|
layoutStyle | enum optional | A different style, or null for an empty room. |
layoutOptions | object optional | Replaces the options wholesale. null clears them. |
guestCount | integer optional | 0–100000. |
startTime | string optional | HH:mm or null. |
endTime | string optional | HH:mm or null. |
notes | string optional | Up to 4000 characters, or null. |
contact | object optional | { name, email, phone }, or null. |
externalReference | string optional | Up to 200 characters, or null. |
Returns
The updated plan request, including the new pendingChanges entry.
Errors worth handling
409 plan_request_cancelled | A cancelled request can't be changed. |
412 precondition_failed | It changed after the ETag you sent. |
Notes
- Changing
layoutStylewithout also sendinglayoutOptionsdrops options that don't apply to the new style.
Example request
curl -sS -X PATCH "$METIS_BASE/v1/plan-requests/pr_12" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-H 'If-Match: "the-etag-you-last-read"' \
-H "Content-Type: application/merge-patch+json" \
-d '{"guestCount": 140, "layoutOptions": {"tableSize": 12, "danceFloor": true}}'const updated = await metis("/v1/plan-requests/pr_12", { method: "PATCH", body: {
"guestCount": 140,
"layoutOptions": {
"tableSize": 12,
"danceFloor": true
}
}, ifMatch: occasion.etag });
console.log(updated);var payload = JsonSerializer.Deserialize<JsonElement>("""
{
"guestCount": 140,
"layoutOptions": {
"tableSize": 12,
"danceFloor": true
}
}
""");
using var content = JsonContent.Create(payload, new MediaTypeHeaderValue("application/merge-patch+json"));
using var request = new HttpRequestMessage(HttpMethod.Patch, "v1/plan-requests/pr_12") { Content = content };
request.Headers.Add("If-Match", etag); // the ETag you last read
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Cancel a plan request
POST/v1/plan-requests/{planRequestId}/cancelscope floorplans:write
The booking fell through. The venue's team sees the request as cancelled and stops work; the plan itself stays in their Metis account. Calling it twice is harmless.
Path parameters
| Name | Type | Description |
|---|---|---|
planRequestId | string required | A plan request id. |
Returns
The cancelled plan request.
Example response
{
"id": "pr_12",
"status": "cancelled",
"statusChangedAt": "2026-09-18T11:00:03Z"
}
Example request
curl -sS -X POST "$METIS_BASE/v1/plan-requests/pr_12/cancel" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const created = await metis("/v1/plan-requests/pr_12/cancel", { method: "POST" });
console.log(created);using var content = new StringContent(string.Empty);
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/plan-requests/pr_12/cancel") { Content = content };
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Download a version as PDF
GET/v1/plan-requests/{planRequestId}/versions/{version}/pdfscope floorplans:read
The finished plan as an A4 landscape PDF with a title block — the thing you attach to the BEO or send to the customer. Authenticated with your API key, so use it for server-side fetches; for a link you can store or email, use the version's pdfShareUrl instead.
Path parameters
| Name | Type | Description |
|---|---|---|
planRequestId | string required | A plan request id. |
version | integer required | Which completed version, starting at 1. |
Returns
200 with application/pdf.
Errors worth handling
409 render_pending | The PDF is still being produced. Wait Retry-After seconds and ask again. |
409 render_failed | Metis couldn't render this version. Contact support with the traceId. |
Example request
curl -sS "$METIS_BASE/v1/plan-requests/pr_12/versions/1/pdf" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-o plan-v1.pdfconst response = await metisRaw("/v1/plan-requests/pr_12/versions/1/pdf");
const bytes = Buffer.from(await response.arrayBuffer()); // save it, or stream it onvar bytes = await http.GetByteArrayAsync("v1/plan-requests/pr_12/versions/1/pdf");
await File.WriteAllBytesAsync("plan-v1.pdf", bytes);Download a version as PNG
GET/v1/plan-requests/{planRequestId}/versions/{version}/pngscope floorplans:read
The same page as a 1754×1240 image — good for a thumbnail in your booking screen or for embedding in an email.
Path parameters
| Name | Type | Description |
|---|---|---|
planRequestId | string required | A plan request id. |
version | integer required | Which completed version, starting at 1. |
Returns
200 with image/png.
Errors worth handling
409 render_pending | Still rendering; retry after Retry-After seconds. |
Example request
curl -sS "$METIS_BASE/v1/plan-requests/pr_12/versions/1/png" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-o plan-v1.pngconst response = await metisRaw("/v1/plan-requests/pr_12/versions/1/png");
const bytes = Buffer.from(await response.arrayBuffer()); // save it, or stream it onvar bytes = await http.GetByteArrayAsync("v1/plan-requests/pr_12/versions/1/png");
await File.WriteAllBytesAsync("plan-v1.png", bytes);Floorplans
List an occasion's floorplans
GET/v1/occasions/{occasionId}/floorplansscope floorplans:read
Every floorplan attached to an occasion, including ones the venue's team created themselves in Metis rather than through you.
Path parameters
| Name | Type | Description |
|---|---|---|
occasionId | string required | An occasion id. |
Returns
The occasion's floorplans in a single page.
Example response
{
"data": [
{
"id": "fp_118",
"occasionId": "occ_42",
"name": "Smith-Jones Wedding — Ballroom",
"roomId": "room_26",
"externalReference": "BEO-7781",
"isShared": false,
"thumbnailUrl": null,
"updatedAt": "2026-09-17T15:12:44Z"
}
],
"hasMore": false,
"nextCursor": null
}
Example request
curl -sS "$METIS_BASE/v1/occasions/occ_42/floorplans" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/occasions/occ_42/floorplans");
console.log(result);using var response = await http.GetAsync("v1/occasions/occ_42/floorplans");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Retrieve a floorplan
GET/v1/floorplans/{floorplanId}scope floorplans:read
One floorplan by id.
Path parameters
| Name | Type | Description |
|---|---|---|
floorplanId | string required | A floorplan id, e.g. fp_118. |
Returns
A floorplan object.
Example response
{
"id": "fp_118",
"occasionId": "occ_42",
"name": "Smith-Jones Wedding — Ballroom",
"roomId": "room_26",
"externalReference": "BEO-7781",
"isShared": false,
"thumbnailUrl": null,
"updatedAt": "2026-09-17T15:12:44Z"
}
Example request
curl -sS "$METIS_BASE/v1/floorplans/fp_118" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION"const result = await metis("/v1/floorplans/fp_118");
console.log(result);using var response = await http.GetAsync("v1/floorplans/fp_118");
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Create a floorplan from a saved layout Preview — returns 501 today
POST/v1/occasions/{occasionId}/floorplansscope floorplans:write
Copies one of the customer's own saved layouts for a room straight into the occasion, skipping the request-and-complete cycle. Use it when the venue already has exactly the plan they want. To start from an empty room and have Metis lay it out, use a plan request instead.
Path parameters
| Name | Type | Description |
|---|---|---|
occasionId | string required | An occasion id. |
Headers
| Header | Type | Description |
|---|---|---|
Idempotency-Key | string optional | A unique value per logical operation (a UUID is ideal). Retrying with the same key within 24 hours returns the original response instead of doing the work twice. Strongly recommended for every POST. |
Body fields
| Field | Type | Description |
|---|---|---|
layoutId | string required | A layout id from GET /v1/rooms/{roomId}. |
name | string optional | Defaults to the occasion name and date. |
externalReference | string optional | Your reference; unique within the occasion. |
Returns
201 Created with the floorplan.
Notes
- Counts toward the venue's Metis plan allowance.
Example request
curl -sS -X POST "$METIS_BASE/v1/occasions/occ_42/floorplans" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"layoutId": "lay_58", "name": "Smith-Jones Wedding — Ballroom", "externalReference": "BEO-7781"}'const created = await metis("/v1/occasions/occ_42/floorplans", { method: "POST", body: {
"layoutId": "lay_58",
"name": "Smith-Jones Wedding — Ballroom",
"externalReference": "BEO-7781"
}, idempotencyKey: crypto.randomUUID() });
console.log(created);var payload = JsonSerializer.Deserialize<JsonElement>("""
{
"layoutId": "lay_58",
"name": "Smith-Jones Wedding — Ballroom",
"externalReference": "BEO-7781"
}
""");
using var content = JsonContent.Create(payload, new MediaTypeHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/occasions/occ_42/floorplans") { Content = content };
request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Links
Create a link to an occasion Preview — returns 501 today
POST/v1/occasions/{occasionId}/linksscope links:create
A short-lived URL that opens the occasion in Metis for a person — put it behind an 'Open in Metis' button in your UI.
Path parameters
| Name | Type | Description |
|---|---|---|
occasionId | string required | An occasion id. |
Body fields
| Field | Type | Description |
|---|---|---|
purpose | enum required | view or edit. |
Returns
A link with an expiry.
Example response
{
"url": "https://metisroomplanner.com/metisroomplanner/planner/?occasion=...",
"expiresAt": "2026-09-18T12:00:00Z"
}
Example request
curl -sS -X POST "$METIS_BASE/v1/occasions/occ_42/links" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-H "Content-Type: application/json" \
-d '{"purpose": "edit"}'const created = await metis("/v1/occasions/occ_42/links", { method: "POST", body: {
"purpose": "edit"
} });
console.log(created);var payload = JsonSerializer.Deserialize<JsonElement>("""
{
"purpose": "edit"
}
""");
using var content = JsonContent.Create(payload, new MediaTypeHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/occasions/occ_42/links") { Content = content };
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Create a link that opens a floorplan Preview — returns 501 today
POST/v1/floorplans/{floorplanId}/linksscope links:create
edit opens the Metis planner — the person must be a signed-in member of the venue's account. view opens a read-only 2D/3D review page.
Path parameters
| Name | Type | Description |
|---|---|---|
floorplanId | string required | A floorplan id. |
Body fields
| Field | Type | Description |
|---|---|---|
purpose | enum required | view or edit. |
Returns
A link with an expiry.
Example request
curl -sS -X POST "$METIS_BASE/v1/floorplans/fp_118/links" \
-H "Authorization: Bearer $METIS_API_KEY" \
-H "Metis-Connection: $METIS_CONNECTION" \
-H "Content-Type: application/json" \
-d '{"purpose": "view"}'const created = await metis("/v1/floorplans/fp_118/links", { method: "POST", body: {
"purpose": "view"
} });
console.log(created);var payload = JsonSerializer.Deserialize<JsonElement>("""
{
"purpose": "view"
}
""");
using var content = JsonContent.Create(payload, new MediaTypeHeaderValue("application/json"));
using var request = new HttpRequestMessage(HttpMethod.Post, "v1/floorplans/fp_118/links") { Content = content };
using var response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<JsonElement>();Coming from Prismm or AllSeated?
If you already integrate with Prismm (formerly AllSeated), Metis exposes a separate compatibility surface
under /prismm-api/ that mirrors those calls and their response envelopes, so an existing
integration can point at Metis by changing the base URL and the credentials. It is a migration aid: new
work should use /v1, which is where new capability lands. Ask Metis for the compatibility
contract (/openapi/prismm-compat.yaml) if you need it.
Versioning
The version is in the path (/v1). Within v1 we will add endpoints, add optional
request fields and add response properties — so write a tolerant client. We will not remove or rename
anything, change a type, or make an optional field required without a new version and notice.
The machine-readable contract is OpenAPI 3.1 at /openapi/v1.yaml, with a browsable reference
at /docs/. Generate your client from it if you'd rather not hand-write one. A few operations
are marked Preview below: they're published and stable in shape, but return
501 not_implemented until they're switched on.
Support
Include the X-Request-Id of the failing call, or the traceId from the problem
document, and we can find it immediately. Never include an API key in a support message — if you think one
has been exposed, have the customer revoke it in Metis first and tell us second.