GoliathDevelopersGet an API key
Reference/Formsgenerated from the gateway manifest

updateForm

Update a lead-capture form by id.

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

Update a lead-capture form by id. Patch semantics - omitted fields are left unchanged; passing fieldConfig replaces the ENTIRE document, fields AND steps, so adding, renaming, reordering, or removing a page is a fieldConfig write: read the current document with getForm, edit it, and send the whole thing back (same shape rules as createForm - a malformed entry or unrecognized mapping is rejected, not silently dropped). 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. The mutation returns the stored fieldConfig, so you can verify the pages landed without a second getForm. Activate or deactivate with status (ACTIVE | INACTIVE); prefer status: INACTIVE over deleteForm to turn a form off without losing its submissions. submitButtonLabel sets the submit button text. styleConfig restyles the rendered form (both the hosted page and the iframe embed): a JSON object replaced whole on each write - read the current value with getForm, merge your changes, and send the merged object back; {} resets everything to defaults. All keys optional. Colors are CSS color strings: primaryColor (buttons/accents), backgroundColor, textColor, errorColor, successColor, inputBackgroundColor, inputBorderColor, inputPlaceholderColor, inputFocusColor (focus ring), buttonTextColor, requiredIndicatorColor, dividerColor, formBorderColor. fontFamily is a font name (e.g. "Georgia"). Pixel numbers (JSON numbers, no units): borderRadius (inputs+buttons corner rounding), spacing (gap between fields), inputBorderWidth, inputFocusRingWidth, labelGap, buttonTopMargin, formMaxWidth, formPadding, formBorderWidth. Enums: layout (vertical | horizontal - responsive field layout), inputHeight / labelSize / buttonSize (small | medium | large), labelWeight / buttonFontWeight (normal | medium | semibold | bold), labelPosition (above | inline | floating), buttonStyle (filled | outlined | rounded), buttonWidth (auto | full), buttonTextTransform (none | uppercase | capitalize), buttonHoverEffect (darken | lighten | none), formShadow (none | sm | md | lg). Booleans: formBorder, showDividers. customCss is NOT free-form CSS: only the rules the form builder Customize-CSS tab can author; anything else is REJECTED naming the offending selector or property. The twelve allowed selectors, with the properties permitted under each: .embed-root (padding, max-width, align-items); .embed-form .form-container (background, padding, border-radius, max-width, box-shadow); .embed-form .form-label (color, font-size, font-weight, margin-bottom); .embed-form .form-input (background, border, border-radius, padding, font-size, color); .embed-form .form-submit (background, color, border-radius, padding, font-size, font-weight); .embed-form .form-error (color, font-size, margin-top); .embed-form .form-field (margin-bottom, gap); and, for a form with a calendar field, .embed-form .cal-day--available (background, color, border-radius), .embed-form .cal-day--selected (background, color), .embed-form .cal-slot-btn (border-color, color, border-radius, background), .embed-form .cal-slot-btn--selected (background, color, border-color) and .embed-form .cal-summary-card (background, border, border-radius, padding). No other selector, pseudo-element or property. Prefer the typed keys above. Unknown or mistyped keys elsewhere in styleConfig are silently ignored at render time (the default applies), so stay within this vocabulary. The slug (the last segment of the public URL) is settable at CREATION - createForm takes an optional slug - but THIS operation does not thread it, so it cannot be changed here. It is NOT immutable: the app's form builder renames it, and a rename changes the public URL for good (old links stop resolving), so "change my form URL" is an app hand-off, not an impossibility. 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.

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": "updateForm",
    "variables": {
      "id": "<id>"
    }
  }'
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.updateForm(
  {
    id: '<id>'
  },
  { 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: 'updateForm',
    variables: {
      id: '<id>'
    }
  }),
})
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.

idID!required
nameString
statusEmbedFormStatus
DRAFTACTIVEINACTIVE
fieldConfigJSON
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": "id",
    "type": "ID!",
    "required": true,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "name",
    "type": "String",
    "required": false,
    "description": null,
    "defaultValue": null
  },
  {
    "name": "status",
    "type": "EmbedFormStatus",
    "required": false,
    "description": null,
    "defaultValue": null,
    "enumValues": [
      "DRAFT",
      "ACTIVE",
      "INACTIVE"
    ]
  },
  {
    "name": "fieldConfig",
    "type": "JSON",
    "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": {
      "update": {
        "id": "<ID>",
        "name": "…",
        "status": "DRAFT",
        "fieldConfig": "…",
        "submitButtonLabel": "…",
        "styleConfig": "…",
        "updatedAt": "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 →