본문 바로가기

문의 폼과 CRM 연동

한 고객사는 여러 사이트를, 한 사이트는 견적·AS·채용 등 여러 문의 폼을 가질 수 있습니다. 각 폼은 독립된 ID·업무 유형·발행 버전을 갖고, 제출 내용은 어드민 CRM(받은문의) 에 함께 쌓입니다. CRM에서 사이트·폼·업무 유형으로 좁혀 보며, 상세는 접수 당시 항목 이름과 선택지를 보여줍니다.

연동 목적SDK저장·조회 위치
사이트별·업무별 여러 폼fetchInquiryForm, submitFormInquiryCRM, 폼별 타입을 유지하는 답변과 접수증
이미 연동한 고정 필드 리드submitInquiry기존 CRM 접수 계약 유지
방문자가 글·답변을 다시 조회하는 상담 게시판submitSiteInquiry, fetchSiteInquiries, fetchSiteInquiryCMS 상담 게시판, 공개/비밀글·답변 조회

새로운 연락 요청 폼에는 submitFormInquiry를 사용하세요. 이름·사업체명·이메일을 모든 폼의 필수로 정하지 않습니다. 폼에는 전화 또는 이메일 연락 역할을 지정하며, 제출할 때 유효한 연락 수단이 하나 이상 필요합니다. 전화만 받는 폼을 위해 가짜 이메일을 만들 필요가 없습니다.

API 키는 블로그 조회와 같은 rtlk_cust_* 키를 사이트 서버에서 사용합니다. 브라우저에 키를 전달하지 마세요. 키가 고객사를 식별하고, 사이트에 한정된 키는 그 사이트에 고정됩니다. 고객사 전체 키에는 siteId가 필수입니다. 폼이 해당 고객사·사이트에 속하지 않으면 404 not_found로 거부됩니다.

같은 사이트에 견적·AS 폼 연결하기

  1. ROOT-ADMIN에서 사이트의 문의 폼을 열어 견적과 AS 폼을 각각 만듭니다.
  2. 견적에는 sales, AS에는 service 업무 유형을 지정합니다. 항목 키·타입·필수 조건·개인정보 동의 문구를 저장하고 두 폼의 ID를 서버 설정에 넣습니다.
  3. 각 폼을 fetchInquiryForm으로 읽어 해당 version과 동의 문구를 보여줍니다. 제출에는 방문자가 본 버전을 그대로 보냅니다.
// 서버 코드 — 같은 사이트, 서로 다른 폼 ID와 발행 버전
import { fetchInquiryForm } from "@roottale/cms-client/server";

const connection = {
  apiKey: process.env.ROOTTALE_API_KEY!,
  siteId: process.env.ROOTTALE_SITE_ID!,
};
const [quote, service] = await Promise.all([
  fetchInquiryForm({ ...connection, formId: process.env.ROOTTALE_QUOTE_FORM_ID! }),
  fetchInquiryForm({ ...connection, formId: process.env.ROOTTALE_SERVICE_FORM_ID! }),
]);
// quote.version이 2이고 service.version이 5일 수 있습니다.
// null이면 접수 중지·미등록·접근할 수 없는 폼입니다.
// definition.privacyConsentText와 definition.fields를 각 폼에 표시합니다.

fetchInquiryForm은 { formId, siteId, formKey, version, definition }을 반환합니다. 항상 현재 정의를 읽으며, 404는 null, 다른 HTTP 오류와 잘못된 정상 응답은 CmsApiError를 던집니다. API 키·네트워크 장애를 ‘폼 없음’으로 처리하지 마세요.

항목은 text/textarea/email/tel/select/multiselect/checkbox/number/date를 지원합니다. requireOneOf: [["trades", "message"]]처럼 여러 항목 중 하나를 요구할 수 있습니다. 선택지는 화면의 라벨이 아닌 value로 제출하며, 복수 선택은 문자열 배열, 숫자는 숫자, 체크박스는 boolean으로 보냅니다. 정의·타입·검증의 공통 계약은 공개 패키지 @roottale/inquiry-forms에 있습니다.

권장 — submitFormInquiry Server Action

아래 attempt는 브라우저가 한 번의 제출을 시작할 때 만든 입력 묶음입니다. Server Action에서 폼·사이트 ID를 서버 설정으로 고정하고 API가 발행 버전과 모든 답변을 검증합니다. 임의 tenant, 업무 유형, 라벨을 제출 데이터로 받지 않습니다.

"use server";

import { submitFormInquiry, type SubmitFormInquiryFields } from "@roottale/cms-client/server";

export async function submitQuote(
  attempt: Omit<SubmitFormInquiryFields, "siteId" | "placement">,
) {
  return submitFormInquiry({
    apiKey: process.env.ROOTTALE_API_KEY!,
    formId: process.env.ROOTTALE_QUOTE_FORM_ID!,
    fields: {
      ...attempt,
      siteId: process.env.ROOTTALE_SITE_ID!,
      placement: "contact/quote",
    },
  });
}

견적 폼을 호출하는 입력 예시는 다음과 같습니다. 실제 필드 키는 관리자에 저장한 정의와 같아야 합니다.

const attempt = {
  version: quote.version,
  answers: {
    phone: "010-1234-5678",
    trades: ["painting", "waterproofing"],
    message: "옥상 방수 견적을 요청합니다.",
  },
  privacyConsent: consentCheckbox.checked,
  idempotencyKey: crypto.randomUUID(), // 새 제출을 시작할 때 한 번만 생성
};
const result = await submitQuote(attempt);
// 응답을 잃었다면 attempt를 그대로 보관한 뒤 submitQuote(attempt)로 재시도합니다.

실행 가능한 전체 예제는 npm 패키지의 다음 파일에 있습니다.

  • examples/nextjs/lib/site-inquiry-forms.ts: 같은 사이트의 두 폼 ID를 서버 설정에 연결
  • examples/nextjs/app/contact/page.tsx: 각 폼의 현재 버전과 정의 조회
  • examples/nextjs/components/site-inquiry-form.tsx: 타입별 입력·동의·실패 표시·같은 제출 재시도
  • examples/nextjs/lib/actions/submit-site-form.ts: 공개 SDK를 사용하는 Server Action

예제의 서버 설정은 ROOTTALE_API_KEY, ROOTTALE_SITE_ID, ROOTTALE_QUOTE_FORM_ID, ROOTTALE_SERVICE_FORM_ID입니다. ROOTTALE_API_BASE는 선택입니다. Turnstile 검증을 사용하는 사이트는 폼 안에 위젯을 연결해 cf-turnstile-response 값을 제공하세요.

접수증과 재시도

type SubmitFormInquiryResult =
  | {
      ok: true;
      receipt: { id: string; inquiryNo: number; receivedAt: string; replayed: boolean };
    }
  | {
      ok: false;
      kind: "api" | "transport" | "invalid_response";
      code: string;
      status: number | null;
      message: string;
      fieldErrors: Record<string, string[]>;
      formErrors: string[];
    };

성공은 DB 저장을 확인한 접수증이 있을 때만 반환합니다. 같은 폼에 동일한 키와 내용으로 재시도하면 기존 접수증과 replayed: true를 받고 문의·알림이 중복되지 않습니다. SDK가 자동 재시도하거나 중복 방지 키를 새로 만들지는 않습니다.

transport·invalid_response·서버 5xx는 저장 여부가 불확실할 수 있습니다. 키와 답변·버전·동의·배치·유입 정보 전체를 함께 유지하고, 결과를 확인할 때까지 편집하거나 새 제출로 바꾸지 마세요. Turnstile 토큰은 새 검증 결과로 갱신할 수 있습니다. 예제는 현재 화면의 메모리에 제출을 보관하므로 확인 전에 새로고침하면 재시도 정보가 사라집니다. 페이지 이동을 포함한 복구가 필요하면 별도 저장 정책을 정해야 합니다.

code처리
validation_errorfieldErrors를 각 입력에, formErrors를 폼 전체에 표시
form_version_conflict폼을 다시 읽고 변경된 항목·동의 문구를 확인한 후 새로 제출
idempotency_key_conflict이미 저장된 제출의 키와 다른 내용. 자동으로 새 키를 만들어 재접수하지 않음
not_found해당 키의 고객사·사이트 범위에서 접수 가능한 폼이 없음
turnstile_failed보안 확인을 갱신
rate_limited잠시 후 같은 제출로 재시도
network_error, invalid_response동일한 제출로 다시 요청해 접수증 확인

폼 버전이 바뀌거나 접수가 중지되더라도 이미 저장된 동일 제출의 재시도는 기존 접수증을 돌려줍니다. 과거 접수는 최신 폼 정의로 다시 해석하지 않습니다.

다중 폼 제출 필드 (SubmitFormInquiryFields)

필드필수설명
siteId고객사 전체 키 사용 시사이트 ID. 사이트 한정 키로 다른 사이트를 지정할 수 없음
version✅방문자가 실제 본 발행 버전
answers✅항목 키 → 문자열·문자열 배열·숫자·boolean
privacyConsent✅사용자가 명시 동의한 경우에만 true
idempotencyKey✅1~200자 영문·숫자·_·-, 제출별 고유 키
placement같은 폼을 여러 위치에 배치한 경우 식별자, 예: home/footer
turnstileToken사이트에서 사용하는 Turnstile 검증 토큰
attributionreadAttribution() 객체. null은 생략
journeyreadJourney()의 배열. 기존 submitInquiry와 달리 JSON 문자열로 바꾸지 않음

유입 정보·방문 여정을 수집한다면 폼의 실제 수집 항목과 동의 문구에 반영하세요. 구조화 답변과 접수 당시 폼·동의 문구는 서버에서 암호화 보존합니다. 기존 리드의 어트리뷰션·방문 여정 연동 설명은 아래를 참고하세요.

기존 연동 — submitInquiry Server Action

기존 리드 API의 고정 필드 계약을 유지할 때 사용합니다. 전화만 받거나 업무별 항목이 다른 새 폼은 위의 다중 폼 API로 연결하세요.

// lib/actions/submit-contact.ts
"use server";

import { submitInquiry } from "@roottale/cms-client/server";

export interface ContactState {
  status: "idle" | "success" | "error";
  message?: string;
  errors?: Partial<Record<"name" | "phone" | "privacyConsent", string>>;
}

export async function submitContact(
  _prev: ContactState,
  formData: FormData,
): Promise<ContactState> {
  const name = (formData.get("name") as string | null)?.trim() ?? "";
  const phone = (formData.get("phone") as string | null)?.trim() ?? "";
  const email = (formData.get("email") as string | null)?.trim() ?? "";
  const businessName = (formData.get("businessName") as string | null)?.trim() ?? "";
  const message = (formData.get("message") as string | null)?.trim() ?? "";
  const turnstileToken =
    (formData.get("cf-turnstile-response") as string | null)?.trim() ?? "";
  const privacyConsent = formData.get("privacyConsent") !== null;

  const errors: ContactState["errors"] = {};
  if (!name) errors.name = "이름을 입력해주세요.";
  if (!phone || phone.length < 7) errors.phone = "연락처를 입력해주세요.";
  if (!privacyConsent) errors.privacyConsent = "개인정보 수집·이용에 동의해주세요.";
  if (Object.keys(errors).length > 0) {
    return { status: "error", message: "필수 항목을 입력해주세요.", errors };
  }

  const result = await submitInquiry({
    apiKey: process.env.ROOTTALE_API_KEY!,
    baseUrl: process.env.ROOTTALE_API_BASE,
    fields: {
      vertical: "tax", // consulting | medical | tax | legal
      contactName: name,
      businessName,
      email,
      phone, // 자동으로 010-1234-5678 형태 포맷됨
      message: message || undefined,
      turnstileToken: turnstileToken || undefined,
      privacyConsent: true, // 사용자가 명시 동의한 경우에만 true
    },
  });

  if (result.ok) {
    return { status: "success", message: "상담 문의가 접수되었습니다." };
  }
  return { status: "error", message: result.message };
}

클라이언트 폼에서는 useActionState(submitContact, { status: "idle" })로 연결합니다.

기존 리드 필드 (SubmitInquiryFields)

필드필수설명
vertical✅consulting | medical | tax | legal
contactName✅이름
businessName✅사업체명
email✅이메일 (.+@.+\..+)
phone✅전화번호 (자동 한국식 포맷)
privacyConsent✅개인정보 수집·이용 동의 — 반드시 사용자 명시 동의
message문의 내용
consultationField상담 분야 라벨
currentSiteUrl현재 사이트 URL
overseasTransferConsentmedical 시 ✅국외이전 동의
leadKindpatient(기본) | sales
turnstileTokenCloudflare Turnstile 토큰. tenant-api secret 검증을 켠 경우 전달
extras임의 추가 항목 (최대 50개, 암호화 보관, CRM 상세에 노출)
attribution유입 first-touch/last-touch — 아래 유입 어트리뷰션 참고
journey방문 여정 JSON 원문 — 아래 방문 여정 참고

extras에 개인정보가 담길 수 있으므로 폼의 동의 고지에 수집 항목을 반영하세요. journey를 함께 보낼 때는 개인정보 수집·이용 동의 문구에 "사이트 이용 기록 (방문 페이지·상호작용)"을 추가하세요.

유입 어트리뷰션 (attribution)

문의가 어느 글·검색·단축링크/QR에서 왔는지를 CRM에 표시하려면 두 줄만 추가하면 됩니다. ROOT-ANALYTICS 비콘이 방문자의 first-touch(처음 도착한 경로·rt_src 토큰·utm·외부 referrer 호스트명)를 30일간 기억하며, readAttribution()(브라우저 전용, @roottale/cms-client/attribution)으로 읽습니다. 식별자가 아니므로 개인정보가 아닙니다.

  1. 폼 안에 hidden input 추가 (클라이언트 컴포넌트):
"use client";
import { useEffect, useState } from "react";
import { readAttribution } from "@roottale/cms-client/attribution";

export function AttributionField() {
  const [value, setValue] = useState("");
  useEffect(() => {
    const attribution = readAttribution();
    if (attribution) setValue(JSON.stringify(attribution));
  }, []);
  return <input type="hidden" name="attribution" value={value} />;
}
// 사용: <form action={...}> ... <AttributionField /> ... </form>
  1. Server Action에서 파싱해 전달:
import { parseAttributionJson, submitInquiry } from "@roottale/cms-client/server";

const attribution = parseAttributionJson(formData.get("attribution")); // 깨진 값은 null
const result = await submitInquiry({
  apiKey: process.env.ROOTTALE_API_KEY!,
  fields: { /* ...표준 필드 */ attribution },
});

InquiryAttribution 형태 (모든 필드 선택):

필드설명
landing_path첫 방문 landing pathname
rt_src단축링크/QR 토큰 (roottale.link 경유 시)
utm_source / utm_medium / utm_campaignUTM 파라미터
utm_term / utm_contentUTM 파라미터(세부)
ad_click광고 클릭 유입 존재 플래그(boolean, true일 때만 존재) — gclid/fbclid 원값은 저장하지 않습니다
referrer외부 referrer 호스트명 (raw URL 아님)
first_touch_atfirst-touch 시각 (ISO 8601)
last_touch_at가장 최근 non-direct 방문 시각(ISO 8601) — first-touch와 별개로 갱신
last_touch_channellast-touch를 유발한 채널 — rs(단축링크/QR) | utm | gcl | rf
last_touch_source / last_touch_medium / last_touch_campaign / last_touch_referrerlast-touch 시점의 UTM/referrer

직접 HTTP 연동 시에는 같은 값을 attr_landing_path, attr_rt_src, attr_utm_source, attr_utm_medium, attr_utm_campaign, attr_utm_term, attr_utm_content, attr_ad_click, attr_referrer, attr_first_touch_at, attr_last_touch_at, attr_last_touch_channel, attr_last_touch_source, attr_last_touch_medium, attr_last_touch_campaign, attr_last_touch_referrer 폼 필드로 보내면 됩니다.

방문 여정 (journey)

문의 제출 직전까지의 방문 경로(페이지 이동·주요 전환 이벤트)를 CRM 상세의 "방문 여정" 패널에서 시간순으로 확인할 수 있습니다. @roottale/analytics-runtime의 readJourney()로 읽은 sessionStorage 링버퍼를 JSON.stringify한 문자열을 그대로 journey 필드(직접 HTTP는 attr_journey 폼 필드)로 보내면 됩니다 — 파싱·검증은 서버가 담당합니다.

의료(medical)·법률(legal) 업종 문의는 서버가 자동으로 "마일스톤 모드"(이벤트 유형·시각만, 방문 페이지 정보 제거)로 강제 전환합니다 — 증상·질환 관심사가 노출되지 않도록 하는 안전장치이므로 별도 설정이 필요 없습니다.

에러 처리

submitInquiry는 throw 하지 않고 구조화된 결과를 반환합니다:

type SubmitInquiryResult =
  | { ok: true }
  | { ok: false; code: string | null; message: string }; // message = 한국어 사용자 메시지
code의미
consent_privacy개인정보 동의 누락
consent_overseasmedical인데 국외이전 동의 누락
missing_fields필수 필드 누락
invalid_email이메일 형식 오류
invalid_vertical허용되지 않는 vertical
turnstile_failedTurnstile 검증 실패
rate_limited같은 tenant/IP에서 요청 과다
invalid_api_key키 인증 실패
internal서버/네트워크 오류

대안 — RootTaleLeadForm 컴포넌트

자체 폼 없이 빠르게 붙일 때는 @roottale/cms-renderer-next의 RootTaleLeadForm(RSC, HTML form)을 사용할 수 있습니다. 디자인·검증을 통제하려면 위의 Server Action 방식을 권장합니다.

RootTaleLeadForm에 turnstileSiteKey를 넘기면 Cloudflare Turnstile 위젯과 cf-turnstile-response 필드가 함께 렌더링됩니다.

raw HTTP로 직접 연동(비 JS 스택)하려면 api-reference.md의 POST /v1/public/inquiries를 참고하세요.

기존 문의 원장 동기화

이미 다른 시스템에서 접수·처리하는 문의는 서버에서 POST /v1/inquiries/sync로 연결합니다. 사이트에 바인딩된 inquiries:sync:write 전용 키가 필요합니다. CMS 읽기 키와 공개 폼 접수 키에는 이 권한을 추가하지 마세요. 예제는 examples/nextjs/lib/sync-external-inquiries.ts입니다.

요청은 { source, observed_at, items }인 해당 원장의 전체 스냅샷입니다. source는 변하지 않는 시스템 식별자, observed_at은 일관된 DB 읽기를 시작한 시각입니다. 각 항목은 external_id, received_at, name, phone, email, message, status가 필요하며, extras와 attribution은 선택입니다. 상태는 new, contacting, contacted, follow_up, converted, closed입니다. 실제 성공 계약이 확인된 경우만 converted를 보냅니다.

  • 같은 사이트·원장·외부 ID는 같은 문의를 갱신합니다. 원본 접수일로 성과에 집계합니다.
  • 전체 스냅샷에서 빠진 문의는 해당 원장의 사본에서 삭제됩니다. 페이지 한 장이나 조회 실패를 빈 배열로 보내면 안 됩니다. 원본 읽기 실패 시 요청 자체를 중단하세요.
  • 이전 시각의 요청과 같은 시각에 내용이 다른 요청은 409입니다. 재시도에는 같은 스냅샷을 사용하거나 최신 전체 스냅샷을 다시 읽습니다.
  • ROOT-ADMIN에서는 조회만 제공합니다. 수정·처리·삭제는 원본에서 관리합니다. 원본 동의 기록을 유지하며 동기화 API가 새로운 동의를 생성하지 않습니다.
  • 연락처·본문·추가 항목은 암호화하고 요청 본문은 API 감사 로그에 남기지 않습니다. 유입에는 query 없는 landing_path, hostname인 referrer, UTM, rt_src, ad_click: true만 보냅니다. 방문 여정과 광고 클릭 ID 원문은 받지 않습니다.
  • 응답은 { inserted, updated, removed, total }입니다. 알림은 다시 발송하지 않습니다. 요청 상한은 1,000건·2 MiB이며 초과 시 부분 전송 없이 중단해야 합니다.