Site Services
Back to Site Services

DEVELOPER GUIDE · CONTACT FORMS

Connect an Astro contact form

API reference· OpenAPI JSON

The public OpenAPI source can be read directly by coding agents and API tools without signing in. It covers the public form schema and submission endpoints plus authenticated admin operations. Its servers entry names production; configure your tool with your provisioned backend origin when working in another environment.

Keep your Astro frontend. Connect a provisioned form through your client website’s same-origin proxy, read its schema and submit JSON.

01 · Start with personal setup

Access is by invitation. Request early access to discuss a project; this does not create an account or activate a service. Before this example can send to a real backend, we must provision a workspace, an environment and an active form-environment binding.

  • Agree the field keys, types, limits, locales, anti-spam settings and retention. The example schema below assumes required email and message fields; it is not a default template that already exists for your account.
  • Allowlist the exact website Origin, including scheme and non-default port: https://www.example.com differs from https://example.com. Provision preview and production environments separately.
  • Obtain the form-environment UUID and the backend HTTPS hostname. The UUID is a public routing identifier, sometimes called a form token; it is not a secret or an API key. No Authorization header or user session is used by these public endpoints. Keep any operator credentials in server configuration only.

Validation and storage are implemented. API, storage and email notifications will form the complete contact-form flow at launch. Operational email setup is pending; an operator inbox UI is planned separately. Agree and test how the responsible person will retrieve and handle legitimate requests before a client goes live.

Request early access →

02 · Configure the client website proxy

This nginx configuration belongs to the client website serving Astro. Replace both placeholders everywhere with the provisioned values in server configuration. These are exact-match locations, anchored to one UUID and two routes. The marketing website’s own ingress only exposes its provisioned submission POST; it is not a schema proxy for client projects.

# Inside the CLIENT WEBSITE's HTTPS server block.
# Replace FORM_ENVIRONMENT_UUID and api.example.invalid during server setup.
location = /api/site-services/forms/FORM_ENVIRONMENT_UUID/schema {
    limit_except GET { deny all; }
    proxy_pass https://api.example.invalid/api/v1/forms/FORM_ENVIRONMENT_UUID/schema;
    include /etc/nginx/snippets/site-services-upstream.conf;
}
location = /api/site-services/forms/FORM_ENVIRONMENT_UUID/submissions {
    limit_except POST { deny all; }
    proxy_pass https://api.example.invalid/api/v1/forms/FORM_ENVIRONMENT_UUID/submissions;
    include /etc/nginx/snippets/site-services-upstream.conf;
}
# Reject every other Site Services route; do not expose admin or auth.
location /api/site-services/ { return 404; }

# /etc/nginx/snippets/site-services-upstream.conf
proxy_set_header Host api.example.invalid;
proxy_set_header Origin $http_origin;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Cookie "";
proxy_set_header Authorization "";
proxy_ssl_server_name on;
proxy_ssl_name api.example.invalid;
proxy_ssl_verify on;
proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_intercept_errors off;
proxy_cache off;

The browser calls /api/site-services/… on its own origin; nginx forwards to /api/v1/… over verified TLS. Host and TLS SNI must both name the backend, not the client website. Preserve the incoming Origin; never replace it with an allowed constant. Response status, JSON and Retry-After pass through without interception.

This example assumes nginx terminates public HTTPS directly and uses the system CA bundle at the shown Linux path. Redirect public HTTP to HTTPS. If TLS terminates at a load balancer, configure trusted proxy/real-IP handling and derive the external scheme only from that trusted hop; blindly forwarding client-supplied headers is unsafe. Set nginx’s body limit at least as high as the agreed form limit; the backend also enforces a global cap. Static-only hosting needs an equivalent server/edge proxy. Astro dev and preview do not install these nginx routes.

03 · Read the contract, then build the payload

GET /api/site-services/forms/FORM_ENVIRONMENT_UUID/schema

A successful GET returns a raw draft 2020-12 JSON Schema, not a data/meta envelope. It is public and cacheable for 60 seconds. This illustrative response corresponds to the agreed example fields; always read your actual provisioned schema.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "Contact",
  "type": "object",
  "properties": {
    "email": {
      "type": "string",
      "format": "email",
      "maxLength": 254,
      "x-labels": {
        "en": "Email",
        "de": "E-Mail"
      },
      "x-placeholders": {}
    },
    "message": {
      "type": "string",
      "maxLength": 2000,
      "x-labels": {
        "en": "Message",
        "de": "Nachricht"
      },
      "x-placeholders": {}
    }
  },
  "required": [
    "email",
    "message"
  ],
  "additionalProperties": false,
  "x-site-services": {
    "locales": [
      "en",
      "de"
    ],
    "honeypot_field": "website",
    "started_at_field": "started_at",
    "min_elapsed_ms": 3000,
    "max_elapsed_ms": 3600000,
    "max_body_bytes": 8192,
    "form_key": "contact"
  }
}

properties and required describe business fields only. locale and the two anti-spam fields are top-level transport metadata removed before field validation. Read their names and timing limits from x-site-services. Do not validate the whole transport body against additionalProperties: false; validate the business-field subset. The backend remains authoritative.

POST /api/site-services/forms/FORM_ENVIRONMENT_UUID/submissions · Content-Type: application/json

{
  "email": "alex@example.com",
  "message": "Can we talk?",
  "locale": "en",
  "website": "",
  "started_at": 1790000000000
}

This timestamp is illustrative: capture Date.now() when the browser form becomes ready, never at build time or at submit time. Keep the honeypot empty for human visitors. Send flat JSON, not { data: … }, FormData or URL-encoded HTML. Convert booleans and integers explicitly; required boolean fields must be true. Unknown business keys fail validation.

04 · Use a small browser transport in Astro

Download this module to src/lib/contact-form.js in your Astro project and import its two functions in a client-side <script> in your .astro component, not its build-time frontmatter. The displayed source is the exact downloadable file exercised by the guide’s browser tests. It is a transport module, not a complete form component or JSON Schema validator.

Download contact-form.js
// Browser transport only. The caller owns the form UI and catches rejected promises.
export async function loadContactForm(id, locale) {
  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
    throw new Error('Use the provisioned form-environment UUID');
  }
  const base = `/api/site-services/forms/${id}`;
  const response = await fetch(`${base}/schema`, {
    credentials: 'omit', signal: AbortSignal.timeout(20000),
  });
  if (!response.ok) throw new Error(`Schema unavailable: ${response.status}`);
  const schema = await response.json(); // Raw JSON Schema, not result.data.
  const rules = schema['x-site-services'];
  if (!rules?.locales?.includes(locale)) throw new Error('Unsupported form locale');
  return { base, schema, rules, locale, startedAt: Date.now() };
}

export async function submitContactForm(session, fields, honeypot = '') {
  const { base, rules, locale, startedAt } = session;
  const elapsed = Date.now() - startedAt;
  if (rules.time_trap_enabled !== false) {
    if (elapsed < rules.min_elapsed_ms) return { kind: 'wait' };
    if (elapsed > rules.max_elapsed_ms) return { kind: 'expired' };
  }
  const body = JSON.stringify({
    ...fields, locale,
    [rules.honeypot_field]: honeypot,
    [rules.started_at_field]: startedAt,
  });
  if (new TextEncoder().encode(body).length > rules.max_body_bytes) {
    return { kind: 'failed', status: 413 };
  }
  const response = await fetch(`${base}/submissions`, {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    credentials: 'omit', body, signal: AbortSignal.timeout(20000),
  });
  const result = await response.json();
  if (response.status === 202 && result?.data?.status === 'accepted') {
    return { kind: 'accepted' };
  }
  if (response.status === 422 && result?.errors && typeof result.errors === 'object') {
    return { kind: 'invalid', errors: result.errors };
  }
  return { kind: 'failed', status: response.status };
}
  1. On browser initialization, await loadContactForm(publicFormEnvironmentId, "en") (or "de") once and retain the returned session. Obtain the public UUID from your component’s configuration. Compare session.schema with your rendered field keys, types and constraints; keep Send disabled if setup fails.
  2. In the submit handler, preventDefault(), check native validity, block concurrent sends and await submitContactForm(session, { email: emailInput.value, message: messageInput.value }, honeypotInput.value). Use your own input references and actual schema keys. Keep the honeypot out of the keyboard and accessibility flow with tabindex="-1", aria-hidden="true" and autocomplete="off" on its hidden wrapper/input as appropriate.
  3. Handle every result below, and catch network, timeout and non-JSON failures. Always clear the busy state in finally. Preserve entries on failure, use an aria-live status, associate errors with known fields and focus the first invalid field or status. Render messages as text, never server HTML. Without JavaScript, explain that sending is unavailable and provide a real contact alternative; native HTML form submission is not supported by this JSON endpoint.

05 · Treat acknowledgement and errors precisely

{ "data": { "status": "accepted" }, "meta": {} }

Only HTTP 202 with data.status === "accepted" produces kind: "accepted". Show “Request accepted” and prevent another send. The same response is returned for silent spam, including a filled honeypot or too-fast submission. It does not prove legitimate storage, email delivery, a human reply, account creation or an invitation.

HTTP 422 uses application/problem+json (RFC 9457). Read the top-level errors map, not data.errors. The following is an errors-member excerpt, not the entire problem document; messages are localized and can be configured.

{
  "errors": {
    "email": [
      {
        "code": "required",
        "message": "This field is required."
      }
    ]
  }
}
  • kind: "invalid": map known field keys to your UI. A started-at error (expired, required or invalid), locale error or unknown key needs form-session/configuration recovery, not an invisible field marker.
  • kind: "wait": wait until min_elapsed_ms has passed; never backdate the timestamp. kind: "expired": offer an explicit restart using loadContactForm again, keep entries and wait the new minimum interval. Never auto-resubmit.
  • kind: "failed": 400 means malformed JSON; 403 means inactive binding or disallowed/missing Origin; 404 means unknown UUID; 413 means body too large; 415 means wrong media type. Fix configuration or payload rather than retrying blindly. 429 requires a pause (honour Retry-After when provided); 5xx is a service failure.
  • The minimal module returns status but not Retry-After; add header exposure in your UI adapter if implementing timed retries. A rejected promise means acceptance could not be confirmed. Preserve entries and offer manual recovery; the request might already have arrived, so an automatic retry can duplicate it.

06 · Verify on the actual client origin

This guide’s automated browser checks execute the downloadable module against mocked API responses. They do not establish a live nginx-to-Rails connection or prove delivery. The schema and submission contract is traced to the backend’s public controllers, schema generator and executing request specs.

  1. After provisioning, validate nginx configuration and certificates. From the client origin, GET the schema and compare every rendered field and limit. Check an unrelated /api/site-services/ path returns 404.
  2. Submit a valid request after the minimum interval; check the exact 202 envelope and independently verify the legitimate stored record through the agreed operator workflow. Check a known invalid field returns 422 after the time trap has passed.
  3. Verify a foreign or missing Origin is refused, expired sessions preserve entries, network failures do not erase input, and keyboard/mobile/error announcements work. Agree a privacy notice and the operational follow-up path before go-live.