GoliathDevelopersGet an API key
Reference/Formsgenerated from the gateway manifest

createForm

Create a lead-capture form.

POST/api/v1/callWRITEidempotent · 24 hruns as the key's owner
View as Markdown

Create a lead-capture form. Requires name and fieldConfig. At least one field is required and at most one calendar field is allowed. Entries must match that shape exactly - a malformed entry (bad type/width/etc.) or an unrecognized mapping is rejected with a validation error naming the offending field. fieldConfig is the form's ENTIRE page-and-field document, in one of two shapes. (a) SINGLE PAGE: a flat array of field objects. (b) MULTI-STEP (pages / steps): { "fields": [ ...the same fields, each with a "stepId" ], "steps": [ { "id": "page-1", "name": "Your details", "title": "...", "subtitle": "...", "ctaLabel": "Continue" } ], "showStepProgress": true }. Multi-page forms ARE supported. Steps are pages in array order, with a "Step N of M" progress indicator unless "showStepProgress" is false. Step "id" is unique; "name" labels the step in the FORM BUILDER only. Visitors see "title"/"subtitle" and "ctaLabel" (button text); a "ctaLabel" on the LAST step BEATS submitButtonLabel. In a ONE-step envelope they render nothing (use headerText / descriptionText / submitButtonLabel). An unknown stepId is REJECTED, as is any "stepId" in shape (a): steps require the envelope. No "stepId" = first page; keep "order" unique and ascending. BRANCHING: a step's "routes" run in order, each { "match": "all" | "any", "conditions": [ { "fieldKey", "anyOf": [option labels; "true"/"false" for a checkbox] } ], "goTo": a LATER step id | "submit" }; "otherwise" (same values) covers the rest (default: next step). Conditions test dropdown/multiselect/checkbox fields on that step or earlier; a bad destination, or a step "id" of "submit", is REJECTED. A skipped step's required fields are not required. "endings": [ { "id", "name", "title", "subtitle", "icon" (check|calendar|mail|heart|star|info|none), "iconColor" (#rrggbb), "workflowGroupIds", "agentIds", "tagIds", "listIds" } ]; "goTo"/"otherwise" "end:<id>" ends there, adding its ids to the form's actions ("submitted" is reserved). "createsLead": true on one step before the last creates the lead when it is done; later answers join that contact. "collectsConsent": true shows consent and terms on that step only (default: the lead step, else the last).Each field object is { fieldKey, label, type, required, order, width, mapping, dropdownOptions?, placeholder?, stepId? } plus the optional keys below. type is one of text|email|phone|number|dollar|dropdown|multiselect|textarea|checkbox|address|calendar; width is full|half|third. mapping is one of firstName|lastName|fullName|email|phone|address|note|none|customField:<id>: fullName is ONE box for the whole name. LENGTH LIMITS, refused at write time (never truncated) with a message naming the field: fieldKey at 200 characters, every dropdownOptions entry (and the formOptionLabel copied from it) at 256. FIELD KEYS beyond the common ones: the list above is NOT the whole vocabulary. hideLabel hides the label visually while leaving it for screen readers. validation takes { minLength, maxLength, pattern, customErrorMessage } and, for an address field, { addressMode: "single" | "split", requireStreet, requireCity, requireState, requireZipCode, requireCountry }. customFieldOptionMappings is [{ formOptionLabel, customFieldOptionId }] and maps the option labels of a dropdown OR multiselect field onto an EXISTING custom field's options instead of minting new ones. Every label (a multiselect answer is split per label) resolves through this table, and an unmapped label is SILENTLY SKIPPED: the submission succeeds and that CRM field stays empty. So map every option. calendarConfig configures a calendar field: { slotDurationMinutes: [..], defaultDurationMinutes, bufferBeforeMinutes, bufferAfterMinutes, maxAdvanceDays, minNoticeHours, appointmentTitleTemplate, defaultLocation?, locationType?: "VIDEO_CALL" | "CUSTOM" | "PHONE_CALL", calendarProvider?: "GOOGLE" | "MICROSOFT", requireCardToConfirm? }. locationType: "VIDEO_CALL" makes the join link minted on the host’s calendar (Google Meet, or Teams when calendarProvider is MICROSOFT) the location; "CUSTOM" (the default) shows defaultLocation: an address or a pasted Zoom link; "PHONE_CALL" mints no link and the host calls the number the booker entered, so the form must have a REQUIRED type: "phone" field mapped to phone, or the write is refused. A calendar field with NO calendarConfig renders no picker at all, and nothing errors, so always send it. CONSENT IS NOT A CHECKBOX FIELD. A type: "checkbox" field is an ordinary answer in submittedData: no wording is captured, no consent snapshot or SmsConsent row is written, so it proves nothing to a carrier, an A2P review or a TCPA dispute. The Legal & Consent fields are the only consent evidence: collectSmsConsent with smsTransactionalConsentText and smsMarketingConsentText, showMarketingConsent with marketingConsentText, and termsAndConditionsText (below). A checkbox whose label reads as SMS consent is REFUSED at write time, naming the field to use instead; on updateForm only a field you ADD is refused, one the form already had is carried forward. A DROPDOWN OR MULTISELECT OPTION worded as consent records just as little and is NOT refused, so never collect consent that way. A NEW CALENDAR FORM CAN BE BUILT AND PUBLISHED ENTIRELY THROUGH THIS API: never say routing has to be set up in the app: author the fields, set assignmentConfig, then updateForm(status: ACTIVE). Activating a CALENDAR form is rejected with "Please add an agent to route form submissions to." when the effective pool is empty, or "Pick a schedule for every assignee on a calendar form." when an assignee has no calendarConfig: both fixable in the publishing call. The gate is CALENDAR-ONLY: a form with no calendar field publishes with an empty pool, it just routes nobody. Some organizations also enforce a connected-mailbox launch gate on the first DRAFT/INACTIVE-to-ACTIVE transition: every assignee must have the form's pinned GOOGLE or MICROSOFT provider connected (either, when none is pinned); if it rejects, connect the named account in the app and retry. Assignees whose calendarConfig.isFixed is true are excluded from rotation and always attend; if every assignee is fixed there is no rotation, and the first assignee is the host. Dedupe (dedupePrimaryField, with dedupeSecondaryField as fallback) only takes effect when allowDuplicateContact is false; it defaults to true, meaning every submission creates a new contact. Optional submitButtonLabel sets the submit button text; optional styleConfig sets the visual styling (see updateForm for the full key vocabulary). New forms start in DRAFT - publish with updateForm(status: ACTIVE). assignmentConfig is the LEAD ROUTING pool. assignees is an ordered list: ARRAY ORDER IS THE ROTATION ORDER among NON-FIXED assignees (isFixed ones always attend): one hosts every booking, two or more is least-recently-booked round-robin. Each is { userId } (from listTeammates, same-org), plus, on a CALENDAR form, a required calendarConfig: { isFixed, checkExternalCalendars }: a calendar form REJECTS an assignee without one; a non-calendar form does not reject one that carries it (stored, unused). teamIds are UserGroup ids added to every created/matched contact; participantUserIds are users attached as PARTICIPANT on it. DESIRED-STATE, REPLACED WHOLE: omit the key and the stored pool is untouched; pass it and the three lists BECOME the pool, so read with getForm, merge, and send the merged object back rather than one list alone. All three keys are required when you send it: pass [] for the ones you do not want, no duplicates. The response echoes the stored pool with names; confirm from that. The EFFECTIVE pool is the one you send, else the stored one, so an already-routed form reactivates without resending it (the calendar publish gate reads the effective pool). SMS/marketing consent, which is the A2P opt-in EVIDENCE surface: collectSmsConsent renders the transactional opt-in checkbox, labelled smsTransactionalConsentText, and smsDisclaimerText is the paragraph BELOW both boxes, which nobody ticks. SEND THE TEXT WITH THE TOGGLE: the snapshot resolves the SAME defaults the renderer resolves, so an OMITTED or null label records the built-in copy, but that copy is generic. NEVER send an empty string for a consent label: "" (unlike null) renders a checkbox with no wording and files that empty string as the evidence: omit it or send null. Turning on collectSmsConsent ALONE also renders a marketing checkbox, worded and recorded from smsMarketingConsentText (or a default), so set that deliberately. showMarketingConsent is the separate standalone SMS marketing checkbox. Enabling it requires BOTH showMarketingConsent: true and non-empty marketingConsentText that explicitly describes SMS/text messages, plus a field with type: "phone" and mapping: "phone"; submissions are recorded as SmsConsent. Never use this pair for email authorization: standalone email consent is not supported. On update, resend the toggle and its SMS wording together. With no marketingConsentText it does not render at all, though the toggle reads as on; an omitted key, not null, records a box never offered. Transactional and marketing consent are DISTINCT: collecting one is not collecting the other. triggerAgentIds wires AI employees to the form: DESIRED-STATE, so the ids you send BECOME the complete set (send [] to detach all), and every submission puts them on the created or matched contact: read the current set from getForm and merge rather than sending one id alone. Legal text: termsAndConditionsText sits under the submit button, privacyPolicyUrl/termsOfServiceUrl link out, and privacyPolicyContent/termsOfServiceContent hold inline copy instead of a link. slackMessageTemplate is the {{token}} body for the Slack notification. The Powered by Goliath footer is NOT settable here (a Scale entitlement). gtmContainerId is a Google Tag Manager container, validated as GTM-XXXX and honoured on the hosted page only, not on an embed. source is the lead-source label stored on the form; getForm returns it and ownerType alongside. Notification DESTINATIONS: enableEmailNotification, enableSmsNotification and enableSlackNotification; the Slack one (and slackMessageTemplate) needs slackChannelId (plus slackChannelName for display). A channel or template without its toggle changes nothing a person will see, so send the toggle with them. getForm READS BACK everything in this paragraph, to verify a write or read the current copy before changing one field. The optional slug IS settable here, and this is the only operation in this API that takes it (updateForm does not thread it - a later rename is an app action): it is the last path segment of the form's public URL, must be 1-128 characters of lowercase letters, digits and hyphens and may not start or end with a hyphen, and must be unique within your organization - a taken slug is rejected with "This slug is already taken", it is never silently suffixed. Omit it and one is generated from the name.

curl https://server.goliathdata.com/api/v1/call \
  -H "Authorization: Bearer $GOLIATH_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "operationId": "createForm",
    "variables": {
      "name": "<String>",
      "fieldConfig": "<JSON>"
    }
  }'
import { GoliathClient } from '@goliath-data/sdk'

const goliath = new GoliathClient({
  apiKey: process.env.GOLIATH_API_KEY!,
  baseUrl: 'https://server.goliathdata.com',
})

const result = await goliath.forms.createForm(
  {
    name: '<String>',
    fieldConfig: '<JSON>'
  },
  { idempotencyKey: crypto.randomUUID() },
)
const res = await fetch('https://server.goliathdata.com/api/v1/call', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.GOLIATH_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID(),
  },
  body: JSON.stringify({
    operationId: 'createForm',
    variables: {
      name: '<String>',
      fieldConfig: '<JSON>'
    }
  }),
})
const { data, errors } = await res.json()

Variables

The example sends only what the schema requires. Some operations also need one of several optional variables (one filter or another, say); the description above says which.

nameString!required
fieldConfigJSON!required
slugString
headerTextString
descriptionTextString
submitButtonLabelString
successMessageString
styleConfigJSON
autoTagIds[ID!]
autoListIds[ID!]
allowDuplicateContactBoolean
dedupePrimaryFieldEmbedFormDedupeField
EMAILPHONE
dedupeSecondaryFieldEmbedFormDedupeField
EMAILPHONE
assignmentConfigEmbedFormAssignmentConfigInput
assignees[EmbedFormAssigneeInput!]!required

Array order = rotation order (RotationService cursor stability depends on stable caller-supplied pool ordering). Size 1 = always-this-user. Size > 1 = LRB round-robin.

teamIds[ID!]!required

UserGroup ids added to every created/matched contact (source: 'FORM').

participantUserIds[ID!]!required

User ids attached as PARTICIPANT on every created/matched contact.

triggerAgentIds[ID!]
collectSmsConsentBoolean
smsDisclaimerTextString
smsTransactionalConsentTextString
smsMarketingConsentTextString
showMarketingConsentBoolean
marketingConsentTextString
termsAndConditionsTextString
privacyPolicyUrlString
termsOfServiceUrlString
privacyPolicyContentString
termsOfServiceContentString
slackMessageTemplateString
gtmContainerIdString
sourceString
enableEmailNotificationBoolean
enableSmsNotificationBoolean
enableSlackNotificationBoolean
slackChannelNameString
slackChannelIdString
Every field, all the way down
variables
[
  {
    "name": "name",
    "type": "String!",
    "required": true,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "fieldConfig",
    "type": "JSON!",
    "required": true,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "slug",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "headerText",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "descriptionText",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "submitButtonLabel",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "successMessage",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "styleConfig",
    "type": "JSON",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "autoTagIds",
    "type": "[ID!]",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "autoListIds",
    "type": "[ID!]",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "allowDuplicateContact",
    "type": "Boolean",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "dedupePrimaryField",
    "type": "EmbedFormDedupeField",
    "required": false,
    "description": null,
    "defaultValue": null,
    "enumValues": [
      "EMAIL",
      "PHONE"
    ]
  },
  {
    "name": "dedupeSecondaryField",
    "type": "EmbedFormDedupeField",
    "required": false,
    "description": null,
    "defaultValue": null,
    "enumValues": [
      "EMAIL",
      "PHONE"
    ]
  },
  {
    "name": "assignmentConfig",
    "type": "EmbedFormAssignmentConfigInput",
    "required": false,
    "description": null,
    "defaultValue": null,
    "fields": [
      {
        "name": "assignees",
        "type": "[EmbedFormAssigneeInput!]!",
        "required": true,
        "description": "Array order = rotation order (RotationService cursor stability depends on stable caller-supplied pool ordering). Size 1 = always-this-user. Size > 1 = LRB round-robin.",
        "defaultValue": null,
        "fields": [
          {
            "name": "userId",
            "type": "ID!",
            "required": true,
            "description": null,
            "defaultValue": null
          },
          {
            "name": "calendarConfig",
            "type": "EmbedFormAssigneeCalendarInput",
            "required": false,
            "description": "Calendar overlay. Required on calendar forms; absent otherwise.",
            "defaultValue": null,
            "fields": [
              {
                "name": "isFixed",
                "type": "Boolean!",
                "required": true,
                "description": null,
                "defaultValue": null
              },
              {
                "name": "checkExternalCalendars",
                "type": "Boolean!",
                "required": true,
                "description": null,
                "defaultValue": null
              }
            ]
          }
        ]
      },
      {
        "name": "teamIds",
        "type": "[ID!]!",
        "required": true,
        "description": "UserGroup ids added to every created/matched contact (source: 'FORM').",
        "defaultValue": null
      },
      {
        "name": "participantUserIds",
        "type": "[ID!]!",
        "required": true,
        "description": "User ids attached as PARTICIPANT on every created/matched contact.",
        "defaultValue": null
      }
    ]
  },
  {
    "name": "triggerAgentIds",
    "type": "[ID!]",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "collectSmsConsent",
    "type": "Boolean",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "smsDisclaimerText",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "smsTransactionalConsentText",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "smsMarketingConsentText",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "showMarketingConsent",
    "type": "Boolean",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "marketingConsentText",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "termsAndConditionsText",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "privacyPolicyUrl",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "termsOfServiceUrl",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "privacyPolicyContent",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "termsOfServiceContent",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "slackMessageTemplate",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "gtmContainerId",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "source",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "enableEmailNotification",
    "type": "Boolean",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "enableSmsNotification",
    "type": "Boolean",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "enableSlackNotification",
    "type": "Boolean",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "slackChannelName",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "slackChannelId",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  }
]

Response

What comes back under data: a list shows as one entry, and a value as its type.

response shape
{
  "data": {
    "embedFormMutation": {
      "create": {
        "id": "<ID>",
        "name": "…",
        "slug": "…",
        "status": "DRAFT",
        "publicUrl": "…",
        "submissionCount": 0,
        "fieldConfig": "…",
        "createdAt": "2026-01-01T00:00:00.000Z",
        "assignmentConfig": {
          "assignees": [
            {
              "userId": "<ID>",
              "user": {
                "id": "<ID>",
                "firstName": "…",
                "lastName": "…"
              },
              "calendarConfig": {
                "isFixed": true,
                "checkExternalCalendars": true
              }
            }
          ],
          "teamIds": [
            "<ID>"
          ],
          "participantUserIds": [
            "<ID>"
          ]
        }
      }
    }
  }
}

Errors

Idempotency

Send an Idempotency-Key of 1 to 255 characters. A finished request retried with the same key replays its stored response, marked Idempotency-Replayed: true; one still in flight returns 409 idempotency_conflict. Keys expire after 24 hours. Write without fear →