v1 API

Integrations

TypeScript client

Install one package, generate a types file for your workspace’s exact collections and forms, and your editor autocompletes your own field names; your build fails the moment you rename one out from under the site.

The npm package

@sedgemark/client is a zero-dependency, edge-safe runtime (it runs in Node, Cloudflare Workers and the browser) plus the sedgemark CLI, which generates a types-only file describing your workspace. The types file has no runtime, is safe to commit, and a stale one is a compile error rather than a bug.

bash
npm i @sedgemark/client
SEDGEMARK_API_KEY=sk_... npx sedgemark generate --base-url https://acme.sedgemark.app

That writes src/sedgemark.types.ts. A key granted collections:read is enough: generating types only reads your schema; add forms:read if you want your forms typed too. Commit the file; in CI, npx sedgemark generate --check exits non-zero when it is stale, so “added a field, forgot to regenerate” fails the build.

typescript
import { createClient } from '@sedgemark/client'
import type { SedgemarkSchema } from './sedgemark.types'

const sm = createClient<SedgemarkSchema>({
  baseUrl: 'https://acme.sedgemark.app',
  apiKey: process.env.SEDGEMARK_API_KEY, // omit entirely for public collections
})

// Typed as your own collection: collection('blog_pst') is a compile error.
const { data, total } = await sm.collection('blog_post').list({ limit: 20 })

// One entry (the bare row), every entry (pagination handled), a media URL.
const post = await sm.collection('blog_post').get(id)
const everything = await sm.collection('blog_post').listAll()
const cover = post.cover_image ? sm.resolveMedia(post.cover_image) : null

Forms with the package

Call sm.form(slug) when the form renders, not inside the submit handler: that call starts the anti-spam token fetch, and the token must be older than the form’s min_submit_seconds by the time the submission lands. Form requests never attach your API key, so this code is safe in a browser.

typescript
import { createClient, SedgemarkApiError } from '@sedgemark/client'

const sm = createClient<SedgemarkSchema>({ baseUrl: 'https://acme.sedgemark.app' })
// No apiKey, and never configure one for browser-side form code.

// When the form RENDERS, this starts the anti-spam token fetch, so the
// token is old enough by the time the visitor submits:
const form = sm.form('contact')

// Later, in the submit handler:
try {
  const result = await form.submit({ email: 'visitor@example.com', message: 'Hello!' })
  // result.id === null means the honeypot was tripped: show the thank-you.
} catch (err) {
  if (err instanceof SedgemarkApiError) {
    err.errors // [{ field, message }]: every failing field at once
    err.code   // 'token_invalid' | 'token_expired' | 'submitted_too_fast' | null
  }
}

Runtime fixes reach you through npm update; regenerating is only ever about your schema. The package README covers the full surface: mediaMeta(), per-call fetch options for Next.js caching and AbortSignal, rate-limit behavior, and server-side form proxying.

The no-install alternative: the generated client

Sedgemark can also generate a complete, self-contained runtime client from your current schema: the right choice when adding a dependency is not an option, and the only flow agents can use without npm access. Nothing to install, no version to keep in step. Regenerate it whenever your schema changes.

GET /api/dashboard/sdk

A dashboard session, or a key granted collections:read.

  • From the dashboard: the API Documentation page has a download button.
  • From the command line: with a key granted collections:read. Form types are emitted only when the key also holds forms:read, so a key minted purely to read content cannot discover what forms exist.
  • From an agent: the MCP get_client tool returns exactly the same file without a second HTTP request. See MCP server.
bash
curl https://acme.sedgemark.app/api/dashboard/sdk \
  -H "Authorization: Bearer $SEDGEMARK_API_KEY" \
  -o src/lib/sedgemark-client.ts

The response is the TypeScript source itself, served as text/plain with a suggested filename of sedgemark-client.ts. Check it into your repository and regenerate it when your collections change: a schema change your code has not caught up with should be a compile error, which only works if the generated file is versioned alongside the code.

It is a client, not a type declaration file

You get a real SedgemarkClient class with working methods, plus helper functions, not a .d.ts of bare interfaces. Nothing else needs installing to use it; it depends only on fetch.

Using the generated client

typescript
import { SedgemarkClient, fetchAll, resolveMedia, excerpt } from './sedgemark-client'

const client = new SedgemarkClient({
  baseUrl: 'https://acme.sedgemark.app',
  apiKey: process.env.SEDGEMARK_API_KEY, // omit entirely for public collections
})

// One page. Typed as your own collection, not `any`.
const { data, total } = await client.blog_post.list({ limit: 20, offset: 0 })

// One entry.
const post = await client.blog_post.get(id)

// Every entry, pagination handled for you.
const everything = await fetchAll((opts) => client.blog_post.list(opts))

// A media field id turned into a URL.
const cover = post.cover_image
  ? resolveMedia(post.cover_image, { baseUrl: 'https://acme.sedgemark.app' })
  : null

// A plain-text summary from a rich_text field.
const summary = excerpt(post.body, { maxLength: 160 })

Each collection becomes a property on the client with two methods, list(opts) and get(id), typed to that collection’s interface. Both wrap the Content API, so the same rules apply: published entries only, offset pagination, the same status codes.

ExportWhat it does
SedgemarkClientThe client class. Takes { baseUrl, apiKey? }.
SedgemarkApiErrorThrown on any non-2xx response, with the parsed body attached.
resolveMedia(id, { baseUrl })Builds the media URL for an asset id.
fetchAll(list, opts?)Drains every page of a list method into one array.
fetchAllPages(list, opts?)The same, as an async generator, one page at a time, for collections too large to buffer.
excerpt(html, opts?)Strips tags from a rich_text value and truncates on a word boundary.

Read-only, and no draft option

The client has no content write methods, because the delivery API has none. Content is written from the dashboard or through the MCP tools. The one exception is form submission, below.

ListOptions is { limit?, offset? } and deliberately has no status. Offering one would hand a caller asking for drafts a page of published entries with no error at all, worse than not offering it.

Branded ids

Id-bearing fields generate as branded string types rather than plain string: a media field as MediaId, a relation as AuthorId or AuthorId[]. Passing an author id where a media id is expected is a compile error.

Brands do not stop you using an id as a URL

A brand is a subtype of string, so <img src={post.cover_image}> still typechecks; and still renders nothing. The brands catch mixing one id type up with another; resolving an id to a URL is on you. Use resolveMedia.

What is left out

Inactive forms and any collection or form your current plan does not cover are omitted from the generated file entirely. A method for one of them would compile and then fail at runtime, which is the worst of both outcomes. If something you expected is missing, check that it is active and covered by your plan, then regenerate.

Errors

typescript
import { SedgemarkApiError } from './sedgemark-client'

try {
  await client.blog_post.get(id)
} catch (err) {
  if (err instanceof SedgemarkApiError) {
    err.status  // the HTTP status
    err.errors  // [{ field, message }] on a form validation failure
    err.code    // 'token_invalid' | 'token_expired' | 'submitted_too_fast' | null
  }
  throw err
}

Forms with the generated client

When your workspace has active forms, the client also gains client.forms.{slug}: the one write it can perform. Requests to it never attach an Authorization header, even if you configured the client with a key, so form code is safe to run in a browser.

typescript
import {
  SedgemarkClient,
  contactFormFields,
  contactHoneypotField,
  validateContactPayload,
} from './sedgemark-client'

// No apiKey, and never configure one for browser-side form code.
const client = new SedgemarkClient({ baseUrl: 'https://acme.sedgemark.app' })

const payload = { email: 'visitor@example.com', message: 'Hello!' }

// Optional: catches problems without a round trip. Returns every failing
// field at once, exactly as the server would.
const errors = validateContactPayload(payload)

if (errors.length === 0) {
  const result = await client.forms.contact.submit(payload)
  // { ok: true, id, submitted_at, ignored?: [...] }
  // result.id === null means the honeypot was tripped: show the thank-you.
}

The generated validator mirrors the server exactly and is never stricter: it normalizes values the same way before measuring them, so it cannot reject something the API would have accepted. It is a UX affordance, not a security boundary; the server revalidates regardless.

The client also handles the anti-spam token for you: it primes the form descriptor when the client is constructed, so submit() never mints a token too late to be valid. Note that submit() deliberately does not validate first: call validate() when you want field errors before the request goes out.

Each form also exports a field spec array, so a generic form component can render itself from your schema instead of hardcoding inputs:

tsx
{contactFormFields.map(({ slug, name, required, values, attrs }) => {
  const { control, ...inputAttrs } = attrs
  return (
    <label>
      {name}{required && ' *'}
      {control === 'textarea' ? (
        <textarea name={slug} {...inputAttrs} />
      ) : control === 'select' ? (
        <select name={slug} {...inputAttrs}>
          <option value="">Choose…</option>
          {values.map((v) => <option value={v}>{v}</option>)}
        </select>
      ) : (
        <input name={slug} {...inputAttrs} />
      )}
    </label>
  )
})}

attrs uses React’s DOM naming: inputMode, autoComplete, maxLength. In plain HTML the equivalents are lowercase. Pasting one into the other’s context silently drops the attribute.

Astro

There is no Sedgemark package for Astro, on purpose. A content-layer loader for the delivery API is about fifteen lines of fetch, and a dependency for that would be one more thing to keep in step with Astro’s own releases. Copy the loader below into your project and adapt it; it pages your content into a normal content collection, so if you have used Astro with local Markdown files this works the same way: same collections, same getEntry.

typescript
// src/content.config.ts
import { defineCollection, z } from 'astro:content'

const SEDGEMARK_BASE_URL = 'https://acme.sedgemark.app'
const API_KEY = import.meta.env.SEDGEMARK_API_KEY // omit entirely for public collections

/** A content-layer loader is ~15 lines of fetch: no extra package needed for it. */
function sedgemarkLoader(collection) {
  return {
    name: `sedgemark:${collection}`,
    load: async ({ store, parseData }) => {
      store.clear()
      const headers = API_KEY ? { Authorization: `Bearer ${API_KEY}` } : {}

      // /api/v1 returns { data, total, limit, offset } and has no hasMore field,
      // so loop until you have seen `total` rows.
      for (let offset = 0; ; ) {
        const url = `${SEDGEMARK_BASE_URL}/api/v1/${collection}?limit=200&offset=${offset}`
        const res = await fetch(url, { headers })
        if (!res.ok) throw new Error(`${collection}: ${res.status}`)
        const page = await res.json()
        for (const row of page.data) {
          store.set({ id: row.id, data: await parseData({ id: row.id, data: row }) })
        }
        offset += page.data.length
        if (page.data.length === 0 || offset >= page.total) break
      }
    },
  }
}

/** A media field holds an asset id. This endpoint 302s to a presigned URL. */
const resolveMedia = (id) => `${SEDGEMARK_BASE_URL}/api/v1/media/${id}`

const blog = defineCollection({
  loader: sedgemarkLoader('blog_post'),
  schema: z
    .object({
      title: z.string(),
      body: z.string(),                    // pre-sanitized HTML
      cover_image: z.string().nullable(),  // a media asset id, not a URL
    })
    .transform((entry) => ({
      ...entry,
      coverImageUrl: entry.cover_image ? resolveMedia(entry.cover_image) : null,
    })),
})

export const collections = { blog }
astro
---
// src/pages/blog/[id].astro
import type { GetStaticPaths } from 'astro'
import { getCollection } from 'astro:content'

// A dynamic route in Astro's default static output needs getStaticPaths.
// Entries are keyed by their Sedgemark entry id.
export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await getCollection('blog')
  return posts.map((post) => ({ params: { id: post.id }, props: { post } }))
}

const { post } = Astro.props
---
{post.data.coverImageUrl && (
  <img src={post.data.coverImageUrl} alt="" />
)}
<Fragment set:html={post.data.body} />

The loader has no schema awareness of its own: your Zod schema is where you shape fields and resolve media ids, as in the example above. It only ever sees published entries, which is the right default for a site build, because /api/v1 never returns drafts.

Nothing above requires living inside content.config.ts. Pulling sedgemarkLoader and resolveMedia out into their own src/sedgemark.ts and importing them from there works the same way and reads better once you use either one from more than one place on the site.

Pagination has no hasMore field

The response is { data, total, limit, offset }. Loop until you have seen total rows, as the example does: there is no flag telling you a page was the last one.

Other frameworks

There is no Next.js, SvelteKit or Nuxt adapter, because none is needed: @sedgemark/client is plain TypeScript over fetch with no framework assumptions, so it works anywhere those do, including per-call fetch options for Next.js’s data cache. Or call the HTTP API directly: it is six endpoints in total, across Content, Media and Forms.