Meridian handbook
Integrating

Member API

How to build your own member portal (a single-page app or a headless site's account area) on the member API at /api/member/v1: what it requires, signing members in through the engine's sign-in screens, the same-origin session cookie and CSRF token, a worked request sequence, the form description, errors, rate limits and the OpenAPI contract.

engine-dmo

This feature is part of the DMO bundle (engine-dmo).

The member API is the JSON surface behind the member portal: everything a business’s people do in the reference portal (edit their organisation and listings, upload photos, invite colleagues, read their member report) is available to a portal you build with another toolchain. It serves the same data under the same rules as the reference portal: the tier’s editable fields, the approval workflow and the partner role permissions are the engine’s, not the client’s.

The full list of operations, with the permission each needs, is in Portal screens and actions. This page is about calling it.

Requirements

SettingValue
server.api.member.enabled in engine.yamltrue (the default)
portal.modeapi or both (the default). api serves only the member API and the sign-in screens; both serves the reference portal too.
OriginYour portal must be served from the same origin as the API, so the session cookie reaches it. On a headless site, proxy /api/member/v1/ and the member sign-in paths under the frontend’s domain.

Signing in

The member API does not sign anyone in. Members sign in through the engine’s own sign-in screens under the portal path (server.portal_path, /members by default): <portal_path>/login, with the methods auth.member enables (magic link, passkeys, password). These screens, and the pages members reach from links in mail, are always rendered by the engine and restyled in templates/portal/.

Sign-in sets the session cookie __Host-mrd_member: secure, host-only and sent only to the same origin. Every API call carries it automatically when your portal runs on that origin and sends credentials.

A request sequence

// 1. Who is signed in, which organisations they act for, and the CSRF token.
const me = await fetch("/api/member/v1/me", { credentials: "same-origin" }).then((r) => r.json());
// me.person, me.organisations[] ({id, name, role, partner_role, permissions, sites, is_primary}),
// me.capabilities, me.csrf

// 2. The organisation's listings.
const org = me.organisations[0];
const listings = await fetch(`/api/member/v1/organisations/${org.id}/listings`, { credentials: "same-origin" }).then((r) => r.json());

// 3. The form this person may fill for one listing at their tier.
const form = await fetch(`/api/member/v1/listings/${listingId}/form`, { credentials: "same-origin" }).then((r) => r.json());

// 4. Save field values. Unsafe methods carry the CSRF token.
const res = await fetch(`/api/member/v1/listings/${listingId}`, {
  method: "PATCH",
  credentials: "same-origin",
  headers: { "Content-Type": "application/json", "X-CSRF-Token": me.csrf },
  body: JSON.stringify({ summary: "Riverside pub with a garden", website: "https://redlion.example" }),
});
// 200: {saved, discarded, status, approval}; 422: a problem with field_errors
Rule
Base path/api/member/v1 on the site’s host.
AuthenticationThe __Host-mrd_member session cookie; a request without a valid session is refused.
Unsafe requestsPOST, PATCH and DELETE carry the CSRF token from GET /me in X-CSRF-Token.
BodiesJSON, except POST /assets, which is multipart/form-data (file, organisation, licence_accepted required; alt unless decorative; optional caption, listing, field).
LanguageListing values are read and written in the site’s source locale.

The form description

GET /listings/{id}/form is the contract a portal draws its listing editor from: the schema’s fields this person may edit at their organisation’s tier, in the locale.

KeyMeaning
localeThe locale the form edits.
groups{key, label, fields}: the fields in the editor’s groups.
fields[]Each {name, type, label, help, required, required_when, visible_when, value, options, max, multiple, constraints, params, fields, blocks, asset, link}: type is the field type, value the working value, options for a select, fields and blocks for containers, asset and link the rules of those field types. visible_when and required_when are JSON predicate trees, never strings.
validationCross-field rules: {rule, fields, message, severity}.
approval{mode, expected_turnaround_hours, review_window_hours, always_review}: whether a save goes to staff review or is published at once with a review window.

engine-dmo api member form --listing <id> --person <id> --project . prints the description a given member would receive.

A PATCH /listings/{id} body is field values by field name. Fields the tier does not permit are discarded and named in discarded, never an error. The answer is {saved, discarded, status, approval}; status is {live, last_live_at, pending, returned, approval}, also served by GET /listings/{id}/status.

Errors

Every failure is an RFC 9457 problem detail (type, title, status, detail). A 422 carries field_errors, each {path, code, message}, with structured paths such as stages[2].title.

Rate limits

Every write is limited per session by endpoints.defaults.rate_limit.member in engine.yaml (60/hour/session by default), and four times that per address.

# config/engine.yaml
endpoints:
  defaults:
    rate_limit:
      member: 120/hour/session

The contract

engine-dmo api member routes --project .    # every operation and whether this project serves it
engine-dmo api member openapi --project .   # the OpenAPI 3.1 document

The same document is served at GET /api/member/v1/openapi.json. The path carries the major version; changes within v1 are additive, so ignore keys you do not know.

What it does not cover

Declared portal screens and actions are portal features and are not exposed through the API, and event submission has no API operation yet (GET /events lists the organisations’ event documents). Forms on the public site are posted through the content API, not this one.