GenieOSdocs

Schema contract

Why GenieOS templates have types — and how the contract keeps your CI, your editor, and your inbox in sync.

The schema contract is the single feature that makes GenieOS different from every other email API on the market. Every template has a typed declaration of the variables it consumes — and the API enforces it.

The result: templates can\u2019t silently grow new variables, sends can\u2019t ship with Hi {{first_name}} in the inbox, and your editor knows exactly what to autocomplete.

What a contract looks like

// templates/welcome/contract.json
{
  "name": "welcome",
  "version": 4,
  "variables": {
    "first_name": { "type": "string", "required": true },
    "trial_days":  { "type": "integer", "default": 14, "required": false },
    "plan":        { "type": "string", "enum": ["hobby", "pro"], "required": true },
    "show_offer":  { "type": "boolean", "default": false, "required": false }
  }
}

The four supported types — string, integer, boolean, string[] — are enough for ~99% of real templates and they map cleanly to TypeScript and Pydantic. We deliberately don\u2019t support free-form objects because they defeat the whole point: if your template needs a deeply nested struct, that struct is something to flatten before sending, not something to embed.

Where the contract comes from

A contract is derived from the template, not authored separately. When you save a template in the dashboard, the editor walks the MJML / HTML looking for {{variable}} references and infers a draft contract. You then confirm types and required-ness; the contract version increments.

Programmatic flow (CI, agents, the CLI) is the same:

genie templates push ./templates/welcome.html \
  --contract ./templates/welcome.contract.json

Pushing without a contract walks the template, infers the variables, and asks you to confirm with --accept.

Enforcement

When you call POST /v1/transactional/send, the API:

  1. Loads the latest published version of the template.
  2. Validates the variables payload against the contract.
  3. Rejects the send with 422 unprocessable_entity if anything is missing, wrong-typed, or out-of-enum.
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "error": {
    "type": "validation_error",
    "code": "schema_contract_violation",
    "message": "Variable `plan` must be one of [\"hobby\", \"pro\"], got \"founders\".",
    "fields": [
      { "path": "variables.plan", "code": "enum_violation" },
      { "path": "variables.first_name", "code": "missing" }
    ],
    "request_id": "req_01J..."
  }
}

Because the failure is structured, your SDK can rethrow it as a typed exception and your forms can highlight the right field. See Errors.

Why version the contract

When you change a template — add a new variable, make an existing one required, narrow an enum — the version increments. Pending sends keep the old contract until they\u2019re flushed; new sends get the new one.

The webhook event schema_contract.changed fires on every published version bump:

{
  "id": "evt_01J...",
  "type": "schema_contract.changed",
  "data": {
    "template": "welcome",
    "from_version": 3,
    "to_version": 4,
    "added": ["plan"],
    "removed": [],
    "tightened": ["first_name"],
    "loosened": [],
    "diff_url": "https://app.genieos.pro/.../diff"
  }
}

This is the hook your CI listens on to fail builds when a template upstream of your service changed. See Webhooks for delivery + signing.

Contracts in the SDKs

Both SDKs ship a generator that turns published contracts into typed function signatures. Run it in CI; commit the output.

npx @genie-os/sdk codegen --out src/genieos.types.ts
src/checkout.ts
import { GenieOS } from 'genieos';
import type { Templates } from './genieos.types';

const gos = new GenieOS();

await gos.templates.send<Templates['welcome']>({
  to: 'ada@example.com',
  template: 'welcome',
  variables: {
    first_name: 'Ada',
    plan: 'pro', // \u2705 autocomplete: 'hobby' | 'pro'
    // \u26a0\ufe0f tsc fails here if `plan` is missing or `trial_days` is a string.
  },
});
genieos codegen --out app/genieos_types.py
app/checkout.py
from genieos import GenieOS
from app.genieos_types import WelcomeVariables

gos = GenieOS()

gos.templates.send(
    to="ada@example.com",
    template="welcome",
    variables=WelcomeVariables(
        first_name="Ada",
        plan="pro",  # \u2705 mypy / pyright: Literal["hobby", "pro"]
    ),
)

Codegen is optional

The SDKs work fine without typed variables — variables is just a Record<string, unknown>. Codegen turns silent runtime 422s into red squiggles in your editor; pick it up when you\u2019re ready.

Contracts at the MCP boundary

When an AI editor uses the MCP to send mail on your behalf, the same contract enforcement applies. The MCP\u2019s send_transactional tool receives the contract as part of its tool schema, so the agent literally cannot propose a malformed call — Cursor / Claude / Continue all surface the missing field before the user hits send.

This is the killer feature of mcp.genieos.pro and the reason we built the contract in the first place: schema is the only way to make AI agents safe email participants.

Authoring tips

  • Default copy reads on its own. If a variable has a sensible fallback ("there" for a missing first name), encode it as default. The contract validator preserves required-ness for human-authored APIs but the rendered email always has a value.
  • Use enum for plans, statuses, and language. It\u2019s the cheapest way to catch typos on the day they happen, not on the day you ship.
  • Avoid boolean toggles in templates. Prefer flipping templates: a welcome.trial and welcome.paid are easier to read in the dashboard than a single welcome with three booleans.

On this page