Documentation

Schema Discovery & Dry Runs

Ask the API what it accepts, check what a key can do, and validate a write without saving it.

Three features exist so an integration - or an AI assistant - never has to guess: it can ask what the API accepts, ask what its own key is allowed to do, and try a write without saving it.


GET /apiSchema

Returns a machine-readable description of every field on every object type. Any valid credential works; no particular scope is required.

The field list is generated from the very rules the server validates against, so it cannot drift the way a hand-written specification does. If the API accepts a field, it is in here; if it is in here, that is the type actually enforced.

Each object accounts for every key a read can return, in four groups, so an unexpected key is a bug on our side rather than something you have to guess about:

  • identifiedBy - the field naming the record a write is about. It is documentId for every object type, so one rule covers the whole API: documentId is this record, a <type>Id references a different one. identifiedByAliases lists the older per-type spelling (sessionId, speakerId, …), still accepted everywhere.
  • fields - writable, with the type, bounds and meaning of each.
  • structuredFields - writable, but validated separately because the value is a structure rather than a scalar. This is where contentData lives.
  • readOnlyFields - returned by reads, ignored on writes, each with the reason.
  • protectedFields - rejected outright, each with the reason.

Alongside the objects it returns:

  • fieldTypes - what every type name means, and what each one accepts.
  • formats - the rich-text and content-section structures, which a type name alone cannot describe.
  • conventions - how record ids, omitted fields, dry runs, errors and rate limits behave.
  • thisEvent - your event’s actual track and tier names, and the range of numbers that are valid for them.
  • endpoints and yourScopes - what exists, and what this key may call.

This is why the AI assistant briefing is so short: rather than pasting a field list into every conversation, it tells the assistant to fetch this.

{
  "baseUrl": "https://api.event-vault.com",
  "requiredHeaders": { "x-api-key": "...", "x-client-id": "...", "x-event-id": "...", "x-timestamp": "..." },
  "conventions": {
    "recordIds": "To EDIT a record, send its id; to CREATE one, leave the id out ...",
    "dryRun": "Add ?dryRun=1 to any write to validate the request ...",
    "omittedFields": "Omitted fields are left unchanged ...",
    "fieldCategories": "Each object lists its keys in four groups ...",
    "protectedFields": "Keys listed under an object's protectedFields are rejected with a 400 ..."
  },
  "thisEvent": {
    "trackArray": ["Main Stage", "Workshops"],
    "trackIndexRange": "0..1",
    "tierArray": ["Gold", "Silver"],
    "tierIndexRange": "0..1"
  },
  "yourScopes": ["sessions:read", "sessions:write"],
  "endpoints": [ { "path": "/upsertSession", "method": "POST", "scope": "sessions:write", "object": "sessions" } ],
  "objects": {
    "sessions": {
      "identifiedBy": "documentId",
      "identifiedByAliases": ["sessionId"],
      "fields": {
        "name": { "type": "string", "required": true, "maxLength": 300, "description": "Session title." },
        "track": { "type": "int", "min": 0, "max": 1, "description": "Index into the event's trackArray ..." },
        "tintColor": { "type": "color", "clearsTo": "#FFFFFFFF", "description": "Overrides the track colour ..." }
      },
      "structuredFields": {
        "contentData": "An ordered array of content sections - the record's body copy ..."
      },
      "readOnlyFields": {
        "speakers": "the session's speaker ids, derived from the speaker sections in contentData. Edit the content, not this.",
        "subscriberCount": "registration count, maintained as attendees subscribe and unsubscribe in the app."
      },
      "protectedFields": {}
    }
  }
}

Note thisEvent: the track and tier ranges are reported for your event, so you know what track values are legal before you send one.


GET /whoami

Describes the credential you are using. Any valid credential works; no particular scope is required.

Useful as a first call: it confirms connectivity and lists the exact permissions, so an integration never has to discover its own limits by collecting 403s.

{
  "auth": "scoped",
  "clientId": "acme",
  "eventId": "ev123",
  "eventName": "Acme Conference 2026",
  "scopes": ["sessions:read", "sessions:write"],
  "scopeDescriptions": { "sessions:write": "Create, edit and delete sessions." },
  "tokenId": "tok_abc",
  "name": "Programme importer",
  "boundEventId": "ev123",
  "expiresAt": "2026-09-01T12:00:00.000Z",
  "rateLimit": { "requests": 300, "windowSeconds": 60, "scope": "per token" }
}

A legacy client-wide key reports "auth": "legacy" and a note recommending a scoped token instead.


?dryRun=1

Add dryRun=1 to the query string - or "dryRun": true to the body - of any write endpoint to validate the request without writing anything.

A dry run is not just a type check. It runs the same path the real write would: referenced ids are looked up, cross-field rules are applied, and the warnings you would have received come back too.

curl -X POST "https://api.event-vault.com/upsertPoll?dryRun=1" \
  -H "x-api-key: $EV_API_KEY" \
  -H "x-client-id: $EV_CLIENT_ID" \
  -H "x-event-id: $EV_EVENT_ID" \
  -H "x-timestamp: $(python3 -c 'import time;print(int(time.time()*1000))')" \
  -H "Content-Type: application/json" \
  -d '{"sessionId":"s42","question":"Ready?","options":["Yes","No"]}'
{
  "status": "success",
  "dryRun": true,
  "message": "Validation passed. Nothing was written because dryRun was set.",
  "created": true,
  "warnings": ["the session 's42' has hasPoll set to false, so this poll will not be reachable in the app until that is enabled (set it with /upsertSession)."]
}

A failing dry run returns the same 400 the real request would have. Destructive endpoints use it to report the damage in advance - deletePoll tells you how many votes would be destroyed, and deleteScoreObject how many attendees would lose points.

Editing? Read changes

When a write resolves to an edit rather than a create, the response carries changes: exactly which fields moved, and from what to what. A dry run reports what would change; the real write reports what did. Both are computed the same way from the same values, so the dry run is a reliable rehearsal.

{
  "status": "success",
  "dryRun": true,
  "documentId": "DdLevoo1",
  "created": false,
  "changes": {
    "location": { "from": "Room A", "to": "Main Hall" },
    "timeEnd": { "from": "10:30", "to": "11:00" }
  }
}

This answers the question that matters most before a batch: am I editing, or duplicating? "created": false with a short changes list is an edit. "created": true means no existing record was matched and a new one is being added - usually because the id was left out. An empty changes means the request wrote nothing new.

Dry-run the first record of a batch and read this before running the other 200, then keep reading it on the real writes to confirm each one landed as you expected.

changes is absent on a create, since everything about a new record is new - created: true is the whole story there. It is also absent from /bulkUpsert, which reports per-item results instead.