문의 폼과 CRM 연동
한 고객사는 여러 사이트를, 한 사이트는 견적·AS·채용 등 여러 문의 폼을 가질 수 있습니다. 각 폼은 독립된 ID·업무 유형·발행 버전을 갖고, 제출 내용은 어드민 CRM(받은문의) 에 함께 쌓입니다. CRM에서 사이트·폼·업무 유형으로 좁혀 보며, 상세는 접수 당시 항목 이름과 선택지를 보여줍니다.
| 연동 목적 | SDK | 저장·조회 위치 |
|---|---|---|
| 사이트별·업무별 여러 폼 | fetchInquiryForm, submitFormInquiry | CRM, 폼별 타입을 유지하는 답변과 접수증 |
| 이미 연동한 고정 필드 리드 | submitInquiry | 기존 CRM 접수 계약 유지 |
| 방문자가 글·답변을 다시 조회하는 상담 게시판 | submitSiteInquiry, fetchSiteInquiries, fetchSiteInquiry | CMS 상담 게시판, 공개/비밀글·답변 조회 |
새로운 연락 요청 폼에는 submitFormInquiry를 사용하세요. 이름·사업체명·이메일을
모든 폼의 필수로 정하지 않습니다. 폼에는 전화 또는 이메일 연락 역할을 지정하며,
제출할 때 유효한 연락 수단이 하나 이상 필요합니다. 전화만 받는 폼을 위해 가짜
이메일을 만들 필요가 없습니다.
API 키는 블로그 조회와 같은 rtlk_cust_* 키를 사이트 서버에서 사용합니다.
브라우저에 키를 전달하지 마세요. 키가 고객사를 식별하고, 사이트에 한정된 키는
그 사이트에 고정됩니다. 고객사 전체 키에는 siteId가 필수입니다. 폼이 해당
고객사·사이트에 속하지 않으면 404 not_found로 거부됩니다.
같은 사이트에 견적·AS 폼 연결하기
- ROOT-ADMIN에서 사이트의 문의 폼을 열어 견적과 AS 폼을 각각 만듭니다.
- 견적에는
sales, AS에는service업무 유형을 지정합니다. 항목 키·타입·필수 조건·개인정보 동의 문구를 저장하고 두 폼의 ID를 서버 설정에 넣습니다. - 각 폼을
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_error | fieldErrors를 각 입력에, 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 검증 토큰 | |
attribution | readAttribution() 객체. null은 생략 | |
journey | readJourney()의 배열. 기존 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 | |
overseasTransferConsent | medical 시 ✅ | 국외이전 동의 |
leadKind | patient(기본) | sales | |
turnstileToken | Cloudflare 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)으로
읽습니다. 식별자가 아니므로 개인정보가 아닙니다.
- 폼 안에 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>
- 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_campaign | UTM 파라미터 |
utm_term / utm_content | UTM 파라미터(세부) |
ad_click | 광고 클릭 유입 존재 플래그(boolean, true일 때만 존재) — gclid/fbclid 원값은 저장하지 않습니다 |
referrer | 외부 referrer 호스트명 (raw URL 아님) |
first_touch_at | first-touch 시각 (ISO 8601) |
last_touch_at | 가장 최근 non-direct 방문 시각(ISO 8601) — first-touch와 별개로 갱신 |
last_touch_channel | last-touch를 유발한 채널 — rs(단축링크/QR) | utm | gcl | rf |
last_touch_source / last_touch_medium / last_touch_campaign / last_touch_referrer | last-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_overseas | medical인데 국외이전 동의 누락 |
missing_fields | 필수 필드 누락 |
invalid_email | 이메일 형식 오류 |
invalid_vertical | 허용되지 않는 vertical |
turnstile_failed | Turnstile 검증 실패 |
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이며 초과 시 부분 전송 없이 중단해야 합니다.