Read in:
English

Forms in notes

Declare a form in a note's frontmatter and trip2g embeds it on the page as a <script id="form-spec"> block, accepts submissions through the GraphQL API, and stores them against the note. Use it for lead collection, homework submission, contact forms, or anything else that takes structured input attached to a page.

The note stays a normal note — visible at its permalink, indexed in search, exportable. The form is just a property of it.

A minimal example

---
title: Say hello
form:
  fields:
    - name: email
      type: email
      required: true
    - name: message
      type: text
      required: true
      max_length: 2000
---

That's it. On render, the page gets:

<script id="form-spec" type="application/json">
  { "note_version_id": 1234, "forms": { "": { "fields": [...] } } }
</script>

The default template auto-renders the form at the end of the page. A custom layout can read the same JSON and lay things out however it likes (see Custom layout below).

Field types

type Maps to Validators
text string required, min_length, max_length, enum: ["a","b"]
email string required (format validated server-side)
int integer required, min, max, enum: [1,2,3]
bool boolean required, enum: [true] (consent checkbox pattern)
file not implemented yet — returns file_upload_not_supported

Every field needs a name and a type. required defaults to false. enum accepts the same primitive type as the field — pick exactly one of the listed values.

Who can submit — can_submit

form:
  can_submit: admin    # guest (default) | admin | paid_user
Value Behaviour
omitted / guest Anyone, including anonymous visitors
admin Only authenticated admins can submit
paid_user Accepted in spec but not enforced yet — server returns not_implemented on submit

When a viewer is allowed to read the note but not to submit, the form still renders. On submit the server returns a FormSubmitDeniedPayload:

{ "__typename": "FormSubmitDeniedPayload", "reason": "admin_required" }

The default layout shows a "sign in as admin" hint. A custom layout can branch on reason and offer whatever UX you want.

Redirect on success — success_url

form:
  success_url: /thanks

After a successful submit the layout navigates the browser to success_url. Relative URLs stay on the same domain; absolute URLs let you point at any page (e.g. an external receipt).

Spam protection — turnstile

Cloudflare Turnstile is on by default on every form. To disable it, set turnstile: false explicitly:

form:
  turnstile: false   # accept submissions without a captcha

How it works:

  • When the form is submitted without a valid token, the server returns TurnstileRequiredPayload { siteKey }.
  • The default layout reads siteKey, renders the Turnstile widget, and resubmits with the token in turnstileToken.
  • Locally (no turnstile-secret-key configured) verification is a no-op — any submit succeeds. In production the secret key is set and the captcha gates every submit.

Combine with can_submit: admin for sensitive forms; on public forms the captcha is the only defence against anonymous spam.

Multiple forms on one note — forms:

When one note hosts more than one form, use forms: (a map of named keys) instead of a single form::

forms:
  contact:
    fields:
      - name: email
        type: email
  survey:
    can_submit: admin
    fields:
      - name: rating
        type: int
        min: 1
        max: 5

The frontend addresses each form by its key — pass formId: "contact" or formId: "survey" when calling submitForm. A single inline form: block is equivalent to forms with the empty-string key.

Reusing a spec — form_ref:

Instead of duplicating the same fields on many notes, define the spec once and point at it:

# In a shared note, e.g. templates/comment_form.md
form:
  fields:
    - name: text
      type: text
      required: true
      max_length: 4000
# Anywhere else
form_ref: "[[templates/comment_form]]"

Or by file path:

form_ref: templates/comment_form.md

Submissions are still stored against the referencing note, but the spec lives in one place. Combine with frontmatter patches to attach a form to every note in a folder without touching individual files:

{ form_ref: "[[templates/comment_form]]" }

Custom layout

A custom Jet layout has full access to the note via note.FormSpecJSON() — embed it as a <script> and read it from JS:

{{ block content() }}
  <article>{{ note.HTMLString() | unsafe }}</article>

  <form id="my-form"></form>
  <div id="my-status"></div>

  <script id="form-spec" type="application/json">
  {{ note.FormSpecJSON() | unsafe }}
  </script>
  <script>
    const spec = JSON.parse(document.getElementById('form-spec').textContent);
    const def = spec.forms[''];
    // build inputs from def.fields, then submit via fetch('/_system/graphql', {...})
  </script>
{{ end }}

There is a working example at docs/_layouts/forms/example.html with field rendering, validation messages, and the success_url redirect — copy it as a starting point.

Submitting via GraphQL

The mutation is part of the public schema — any HTTP client can call it.

mutation Submit($input: SubmitFormInput!) {
  submitForm(input: $input) {
    __typename
    ... on SubmitFormPayload          { submitId }
    ... on FormSubmitDeniedPayload    { reason }
    ... on ErrorPayload               { message byFields { name value } }
  }
}

Variables for a single inline form:

{
  "input": {
    "noteVersionId": 1234,
    "formId": "",
    "fields": [
      { "name": "email",   "stringValue": "alice@example.com" },
      { "name": "rating",  "intValue":    5 },
      { "name": "agree",   "boolValue":   true }
    ]
  }
}

noteVersionId comes from the note_version_id field embedded in <script id="form-spec">. formId is "" for an inline form: block; for forms: maps, pass the named key.

Response __typename Meaning
SubmitFormPayload Accepted; submitId is the row id
FormSubmitDeniedPayload reason = admin_required / paid_required / not_implemented
ErrorPayload Validation failed; message describes it, byFields[] lists per-field issues

Submissions enqueue an email to vault admins automatically.

Reading submissions

Admin panel

The admin panel has a "Forms" section per note: list of submissions with timestamps, IPs, statuses, and the values per field. From there you can mark a submission as processed.

Via API key

To pull submissions programmatically, use an admin personal token (Authorization: Bearer t2g_…). The token must belong to an admin user — non-admin tokens get unauthorized. Send all queries to /_system/graphql with Content-Type: application/json and Authorization: Bearer $TRIP2G_TOKEN.

Find which notes have submissions

query { admin { formNotes { notePathId path title submitCount } } }

Use this first to get the notePathId values you'll filter by.

List submissions

query AdminFormSubmits($filter: AdminFormSubmitsFilterInput) {
  admin {
    formSubmits(filter: $filter) {
      totalCount
      nodes {
        id
        noteVersionId
        formId
        ip
        status
        createdAt
        processedAt
        comment
        fields {
          __typename
          ... on AdminFormStringValue { name value }
          ... on AdminFormIntValue    { name iv: value }
          ... on AdminFormBoolValue   { name bv: value }
        }
      }
    }
  }
}

Filter variables — all fields are optional:

Field Type Description
notePathId Int64 Submissions for a specific note
formId String Filter by named form key ("" for inline form:)
status String "pending" or "processed"
processed Boolean false = unprocessed only
createdAt.gte / .lte DateTime Date range
limit Int Page size
offset Int Pagination offset

Example — unprocessed submissions for a specific note in May 2026:

{
  "filter": {
    "notePathId": 123,
    "formId": "contact",
    "processed": false,
    "createdAt": { "gte": "2026-05-01T00:00:00Z", "lte": "2026-05-31T23:59:59Z" },
    "limit": 50,
    "offset": 0
  }
}

Mark a submission as processed

mutation MarkProcessed($input: MarkFormSubmitProcessedInput!) {
  admin {
    markFormSubmitProcessed(input: $input) {
      __typename
      ... on MarkFormSubmitProcessedPayload {
        submit { id processedAt comment }
      }
      ... on ErrorPayload {
        message
        fields { name message }
      }
    }
  }
}

Variables:

{ "input": { "id": 17, "comment": "answered via email" } }

The comment field is optional — useful for leaving a note on how you handled the submission.

Bulk: enable a form on a whole folder

Pair forms with frontmatter patches to roll out a comment form (or contact form) across a section without editing every note:

// blog/**.md
{ form_ref: "[[templates/comment_form]]" }

All notes under blog/ then expose the shared form. Add exclude: ["blog/drafts/*"] to skip work-in-progress notes.

What's not implemented yet

  • type: file — file uploads return file_upload_not_supported on submit. Use a separate object-storage upload step for now.
  • can_submit: paid_user — recognised in frontmatter but server returns FormSubmitDeniedPayload { reason: "not_implemented" }. Use admin until this lands.
  • Captcha providers other than Cloudflare Turnstile — hCaptcha and Yandex SmartCaptcha are on the roadmap (Turnstile is wired up and on by default; see "Spam protection" above).
  • Frontmatter patches — bulk-apply form: or form_ref: to a folder
  • Webhooks — get notified outside the admin panel when a submission arrives
  • Self-hosted — running your own instance, where the GraphQL endpoint and API keys live