폼 응답 API

폼 응답 및 제출 데이터를 조회하기 위한 API 레퍼런스

폼 응답 API

폼 응답 API로 폼 제출 데이터를 조회하고 분석할 수 있습니다. 페이지네이션을 지원하며, 전체 응답 목록 조회, 필터를 적용한 응답 검색, 개별 응답 상세 조회가 가능합니다.

엔드포인트

폼 응답 조회 (페이지네이션)

페이지네이션을 지원하는 응답 목록을 반환합니다.

GET /open-api/v1/forms/{formId}/responses

파라미터

경로 파라미터

파라미터타입필수설명
formIdstring고유한 폼 식별자

쿼리 파라미터

파라미터타입필수기본값설명
pageinteger아니오1페이지 번호 (1부터 시작)
limitinteger아니오20페이지당 응답 수 (최대 100)
customerKeystring아니오특정 customerKey를 가진 응답만 조회

참고: 이 엔드포인트는 단일 customerKey 필터만 지원합니다. 기간·히든 필드·응답 필드 값 등 더 풍부한 필터가 필요하면 아래 응답 검색 엔드포인트를 사용하세요.

요청

요청 예시

# 첫 페이지 조회 (기본값)
curl -X GET "https://api.walla.my/open-api/v1/forms/form_abc/responses" \
  -H "X-WALLA-API-KEY: your_api_key_here" \
  -H "Content-Type: application/json"

# 사용자 지정 제한으로 특정 페이지 조회
curl -X GET "https://api.walla.my/open-api/v1/forms/form_abc/responses?page=2&limit=50" \
  -H "X-WALLA-API-KEY: your_api_key_here" \
  -H "Content-Type: application/json"

응답

성공 응답 (200 OK)

{
  "success": true,
  "data": {
    "responses": [
      {
        "responseId": "response_123",
        "customerKey": "customer_001",
        "submittedAt": "2024-01-15T14:30:00Z",
        "question_1": "Very satisfied",
        "question_2": 5,
        "question_3": ["Feature A", "Feature B"],
        "question_4": "Great product!"
      },
      {
        "responseId": "response_456",
        "customerKey": "customer_002",
        "submittedAt": "2024-01-15T15:45:00Z",
        "question_1": "Satisfied",
        "question_2": 4,
        "question_3": ["Feature A"],
        "question_4": "Good experience"
      }
    ],
    "pagination": {
      "page": 1,
      "limit": 20,
      "totalCount": 150,
      "totalPages": 8
    }
  }
}

응답 필드

필드타입설명
successboolean요청 성공 여부
dataobject응답 데이터 컨테이너
data.responsesarray응답 객체의 배열
data.responses[].responseIdstring고유한 응답 식별자
data.responses[].customerKeystring | null고객 식별자 (전달 시 제공된 경우)
data.responses[].submittedAtstring (date-time)제출 타임스탬프
data.responses[].*any동적 폼 필드 값
data.paginationobject페이지네이션 메타데이터
data.pagination.pageinteger현재 페이지 번호
data.pagination.limitinteger페이지당 응답 수
data.pagination.totalCountinteger필터를 적용한 전체 응답의 정확한 개수
data.pagination.totalPagesinteger전체 페이지 수

참고: 응답 객체에는 폼 구조에 따른 동적 필드가 포함됩니다. 각 폼 필드의 ID가 키로, 필드 타입에 따른 값이 동적으로 들어갑니다. 필드 타입별 출력 형식은 필드 출력 스키마 문서를 참고하세요.

오류 응답

  • 400 Bad Request: 잘못된 페이지 번호 또는 제한

    {
      "error": "Invalid page number. Page must be >= 1"
    }
  • 403 Forbidden: 인증 실패 또는 권한 부족

  • 404 Not Found: 폼을 찾을 수 없음

  • 500 Internal Server Error: 서버 오류


응답 검색 (필터)

응답 목록을 풍부한 필터와 함께 조회합니다. 응답 형식은 위의 페이지네이션 목록과 동일하지만, Parquet 내보내기동일한 필터 계약(customerKeys, 제출일시 범위, 히든 필드, 응답 필드)을 JSON 본문으로 받습니다. 쿼리 스트링으로 표현하기 어려운 중첩 필터를 다룰 수 있어, 단일 customerKey 필터만으로 부족할 때 사용합니다.

POST /open-api/v1/forms/{formId}/responses/search

파라미터

경로 파라미터

파라미터타입필수설명
formIdstring고유한 폼 식별자

요청 본문 (모든 필드 선택)

필드타입기본값설명
pageinteger1페이지 번호 (1부터 시작)
limitinteger20페이지당 응답 수 (최대 100)
customerKeysstring[]지정한 customerKey를 가진 응답만 포함. 빈 배열이거나 생략하면 전체
submittedFromstring (date-time)이 시각 이후(이 시각 포함)에 제출된 응답만. ISO 8601
submittedTostring (date-time)이 시각 이전(이 시각 포함)에 제출된 응답만. ISO 8601
hiddenFieldsobject히든 필드 라벨 → 값. 정확히 일치하는 응답만. 등록되지 않은 라벨은 400
responseFieldsobject응답 필드 ID → 값(문자열) 또는 값 배열

참고: 본문이 비어 있으면 폼의 전체 응답을 페이지 단위로 반환합니다. 서로 다른 필터는 AND로 결합됩니다. responseFields의 값 형태별 해석(필드 타입에 따른 단일/배열 값 의미, 미지원 타입)은 Parquet 내보내기의 응답 필드 필터 의미론과 동일합니다.

요청

요청 예시

# 전체 응답 첫 페이지
curl -X POST "https://api.walla.my/open-api/v1/forms/form_abc/responses/search" \
  -H "X-WALLA-API-KEY: your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "page": 1, "limit": 50 }'

# 기간 + 고객 키 + 히든 필드 + 응답 필드 필터
curl -X POST "https://api.walla.my/open-api/v1/forms/form_abc/responses/search" \
  -H "X-WALLA-API-KEY: your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "page": 1,
    "limit": 50,
    "submittedFrom": "2024-01-01T00:00:00Z",
    "submittedTo": "2024-01-31T23:59:59Z",
    "customerKeys": ["customer_001", "customer_002"],
    "hiddenFields": { "utm_source": "newsletter" },
    "responseFields": {
      "field_score": "5",
      "field_features": ["Feature A", "Feature B"]
    }
  }'

응답

성공 응답 (200 OK)

응답 형식은 폼 응답 조회와 동일합니다. pagination.totalCount필터를 적용한 전체 응답의 정확한 개수입니다(현재 페이지가 마지막인지와 무관).

{
  "success": true,
  "data": {
    "responses": [
      {
        "responseId": "response_123",
        "customerKey": "customer_001",
        "submittedAt": "2024-01-15T14:30:00Z",
        "field_score": "5",
        "field_features": ["Feature A", "Feature B"]
      }
    ],
    "pagination": {
      "page": 1,
      "limit": 50,
      "totalCount": 27,
      "totalPages": 1
    }
  }
}

참고: 마스킹이 설정된 필드는 키는 유지되고 값이 문자열 REDACTED로 대체되어 반환됩니다. Open API에서는 마스킹 해제를 제공하지 않으며(대시보드는 다운로드 사유 기록 후 마스킹 값 열람 가능), 마스킹 필드를 responseFields 필터로 사용하는 것은 지원되지 않습니다. 마스킹 필드를 통째로 제외하는 Parquet 내보내기와는 처리 방식이 다릅니다.

오류 응답

  • 400 Bad Request: 요청 본문 형식 위반, 등록되지 않은 히든 필드 라벨, 또는 미지원 응답 필드. detailsType으로 원인을 구분합니다 (schema / unknownHiddenFieldLabel / unsupportedResponseField).
    {
      "error": "Unsupported response field",
      "detailsType": "unsupportedResponseField",
      "details": [
        { "fieldId": "field_file", "reason": "unsupported_type", "fieldType": "FILE_UPLOAD" }
      ]
    }
  • 401 Unauthorized: API 키 누락 또는 유효하지 않음
  • 403 Forbidden: 권한 없음 (다른 팀/워크스페이스의 폼)
  • 404 Not Found: 폼을 찾을 수 없음

활동 로그와 PII 보호

응답 검색 요청은 활동 로그에 기록되지만, 필터로 전달한 raw 값(이메일 주소, 전화번호 등)은 로그에 남지 않습니다. 필드 라벨, fieldId, 적용된 값의 개수, 필터 사용 여부(boolean)만 기록됩니다.


특정 응답 조회

특정 응답의 상세 정보를 반환합니다.

GET /open-api/v1/forms/{formId}/responses/{responseId}

파라미터

경로 파라미터

파라미터타입필수설명
formIdstring고유한 폼 식별자
responseIdstring고유한 응답 식별자

요청

요청 예시

curl -X GET "https://api.walla.my/open-api/v1/forms/form_abc/responses/response_123" \
  -H "X-WALLA-API-KEY: your_api_key_here" \
  -H "Content-Type: application/json"

응답

성공 응답 (200 OK)

{
  "success": true,
  "data": {
    "responseId": "response_123",
    "formId": "form_abc",
    "teamId": "team_456",
    "workspaceId": "workspace_789",
    "response": {
      "question_1": "Very satisfied",
      "question_2": 5,
      "question_3": ["Feature A", "Feature B"],
      "question_4": "Great product!",
      "email": "john@example.com",
      "name": "John Doe"
    },
    "submittedAt": "2024-01-15T14:30:00Z",
    "customerKey": "customer_001",
    "startedAt": "2024-01-15T14:25:00Z"
  }
}

응답 필드

필드타입설명
successboolean요청 성공 여부
dataobject응답 세부 정보
data.responseIdstring고유한 응답 식별자
data.formIdstring폼 식별자
data.teamIdstring팀 식별자
data.workspaceIdstring워크스페이스 식별자
data.responseobject폼 필드 값 (동적 구조)
data.submittedAtstring (date-time) | null제출 타임스탬프 (제출되지 않은 경우 null)
data.customerKeystring | null고객 식별자
data.startedAtstring (date-time)응답이 시작된 시간

오류 응답

  • 400 Bad Request: 잘못된 요청
  • 403 Forbidden: 인증 실패 또는 권한 부족
  • 404 Not Found: 폼 또는 응답을 찾을 수 없음
  • 500 Internal Server Error: 서버 오류

응답 데이터 이해하기

동적 응답 구조

폼 응답에는 폼 구조에 따라 동적 필드가 포함됩니다. 응답의 키는 폼 필드 ID이며, 값은 필드 타입에 따라 문자열, 배열, 객체 등 다양한 형태로 반환됩니다. 각 필드 타입별 출력 형식은 필드 출력 스키마 문서에서 확인할 수 있습니다.

폼 구조 예시:

{
  "questions": [
    { "id": "satisfaction", "type": "rating", "label": "How satisfied are you?" },
    { "id": "feedback", "type": "text", "label": "Additional comments" },
    { "id": "features", "type": "multiple_choice", "label": "Which features do you use?" }
  ]
}

응답 예시:

{
  "responseId": "response_123",
  "satisfaction": 5,
  "feedback": "Excellent service!",
  "features": ["Feature A", "Feature C"],
  "submittedAt": "2024-01-15T14:30:00Z"
}

응답 타임라인

응답에는 두 가지 타임스탬프가 기록됩니다.

  • startedAt: 수신자가 폼에 처음 접근한 시각
  • submittedAt: 수신자가 폼을 제출한 시각
    • null이면 폼을 열었지만 아직 제출하지 않은 상태입니다

완료 시간 계산:

const timeToComplete = new Date(response.submittedAt) - new Date(response.startedAt);
const minutes = timeToComplete / 1000 / 60;
console.log(`Completed in ${minutes.toFixed(2)} minutes`);

코드 예시

JavaScript/TypeScript

interface PaginationParams {
  page?: number;
  limit?: number;
}

interface ResponseData {
  responseId: string;
  customerKey: string | null;
  submittedAt: string;
  [key: string]: any; // Dynamic form fields
}

interface PaginatedResponse {
  responses: ResponseData[];
  pagination: {
    page: number;
    limit: number;
    totalCount: number;
    totalPages: number;
  };
}

class WallaResponsesAPI {
  constructor(private apiKey: string) {}

  async getResponses(
    formId: string,
    params: PaginationParams = {}
  ): Promise<PaginatedResponse> {
    const { page = 1, limit = 20 } = params;
    const url = new URL(
      `https://api.walla.my/open-api/v1/forms/${formId}/responses`
    );
    url.searchParams.set('page', page.toString());
    url.searchParams.set('limit', limit.toString());

    const response = await fetch(url.toString(), {
      method: 'GET',
      headers: {
        'X-WALLA-API-KEY': this.apiKey,
        'Content-Type': 'application/json'
      }
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();
    return data.data;
  }

  async getResponseById(formId: string, responseId: string) {
    const response = await fetch(
      `https://api.walla.my/open-api/v1/forms/${formId}/responses/${responseId}`,
      {
        method: 'GET',
        headers: {
          'X-WALLA-API-KEY': this.apiKey,
          'Content-Type': 'application/json'
        }
      }
    );

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();
    return data.data;
  }

  async getAllResponses(formId: string): Promise<ResponseData[]> {
    const allResponses: ResponseData[] = [];
    let page = 1;
    let hasMore = true;

    while (hasMore) {
      const data = await this.getResponses(formId, { page, limit: 100 });
      allResponses.push(...data.responses);
      hasMore = page < data.pagination.totalPages;
      page++;
    }

    return allResponses;
  }

  async exportToCSV(formId: string): Promise<string> {
    const responses = await this.getAllResponses(formId);

    if (responses.length === 0) {
      return '';
    }

    // Get all unique keys from responses
    const keys = Array.from(
      new Set(responses.flatMap(r => Object.keys(r)))
    );

    // Create CSV header
    const csv = [
      keys.join(','),
      ...responses.map(response =>
        keys.map(key => {
          const value = response[key];
          if (Array.isArray(value)) {
            return `"${value.join(', ')}"`;
          }
          return typeof value === 'string' && value.includes(',')
            ? `"${value}"`
            : value;
        }).join(',')
      )
    ].join('\n');

    return csv;
  }
}

// Usage
const api = new WallaResponsesAPI(process.env.WALLA_API_KEY!);

// Get paginated responses
const page1 = await api.getResponses('form_abc', { page: 1, limit: 50 });
console.log(`Total responses: ${page1.pagination.totalCount}`);

// Get specific response
const response = await api.getResponseById('form_abc', 'response_123');
console.log(response);

// Export all responses to CSV
const csv = await api.exportToCSV('form_abc');

Python

import requests
import csv
import io
from typing import List, Dict, Any, Optional

class WallaResponsesAPI:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.base_url = "https://api.walla.my/open-api/v1"
        self.headers = {
            "X-WALLA-API-KEY": api_key,
            "Content-Type": "application/json"
        }

    def get_responses(
        self,
        form_id: str,
        page: int = 1,
        limit: int = 20
    ) -> Dict[str, Any]:
        """Get paginated form responses"""
        url = f"{self.base_url}/forms/{form_id}/responses"
        params = {"page": page, "limit": limit}

        response = requests.get(url, headers=self.headers, params=params)
        response.raise_for_status()
        return response.json()["data"]

    def get_response_by_id(
        self,
        form_id: str,
        response_id: str
    ) -> Dict[str, Any]:
        """Get specific response details"""
        url = f"{self.base_url}/forms/{form_id}/responses/{response_id}"
        response = requests.get(url, headers=self.headers)
        response.raise_for_status()
        return response.json()["data"]

    def get_all_responses(self, form_id: str) -> List[Dict[str, Any]]:
        """Get all responses by paginating through all pages"""
        all_responses = []
        page = 1

        while True:
            data = self.get_responses(form_id, page=page, limit=100)
            all_responses.extend(data["responses"])

            if page >= data["pagination"]["totalPages"]:
                break

            page += 1

        return all_responses

    def export_to_csv(self, form_id: str, filename: str) -> None:
        """Export all responses to CSV file"""
        responses = self.get_all_responses(form_id)

        if not responses:
            print("No responses to export")
            return

        # Get all unique keys
        keys = set()
        for response in responses:
            keys.update(response.keys())

        # Write CSV
        with open(filename, 'w', newline='', encoding='utf-8') as f:
            writer = csv.DictWriter(f, fieldnames=sorted(keys))
            writer.writeheader()
            writer.writerows(responses)

        print(f"Exported {len(responses)} responses to {filename}")

    def get_response_statistics(self, form_id: str) -> Dict[str, Any]:
        """Calculate basic statistics for form responses"""
        responses = self.get_all_responses(form_id)

        if not responses:
            return {"total": 0}

        # Calculate completion time statistics
        completion_times = []
        for resp in responses:
            detail = self.get_response_by_id(form_id, resp["responseId"])
            if detail["submittedAt"] and detail["startedAt"]:
                from datetime import datetime
                started = datetime.fromisoformat(
                    detail["startedAt"].replace('Z', '+00:00')
                )
                submitted = datetime.fromisoformat(
                    detail["submittedAt"].replace('Z', '+00:00')
                )
                completion_times.append(
                    (submitted - started).total_seconds() / 60
                )

        stats = {
            "total": len(responses),
            "with_customer_key": sum(
                1 for r in responses if r.get("customerKey")
            ),
            "average_completion_time_minutes": (
                sum(completion_times) / len(completion_times)
                if completion_times else None
            )
        }

        return stats

# Usage
api = WallaResponsesAPI(os.getenv("WALLA_API_KEY"))

# Get responses
data = api.get_responses("form_abc", page=1, limit=50)
print(f"Total responses: {data['pagination']['totalCount']}")

# Get all responses
all_responses = api.get_all_responses("form_abc")

# Export to CSV
api.export_to_csv("form_abc", "responses.csv")

# Get statistics
stats = api.get_response_statistics("form_abc")
print(f"Statistics: {stats}")

사용 사례

데이터 내보내기 및 분석

// 응답 내보내기 및 분석
const responses = await api.getAllResponses('form_abc');

// 평균 평점 계산
const ratings = responses
  .map(r => r.rating)
  .filter(r => typeof r === 'number');
const avgRating = ratings.reduce((a, b) => a + b, 0) / ratings.length;

// 고객 세그먼트별 그룹화
const bySegment = responses.reduce((acc, response) => {
  const segment = response.segment || 'unknown';
  acc[segment] = acc[segment] || [];
  acc[segment].push(response);
  return acc;
}, {});

실시간 모니터링

// 새로운 응답 폴링
let lastCheck = new Date();

setInterval(async () => {
  const data = await api.getResponses('form_abc', { page: 1, limit: 10 });
  const newResponses = data.responses.filter(r =>
    new Date(r.submittedAt) > lastCheck
  );

  if (newResponses.length > 0) {
    console.log(`${newResponses.length} new responses!`);
    // 새로운 응답 처리
  }

  lastCheck = new Date();
}, 60000); // 매분마다 확인

분석 도구와의 통합

# 분석 플랫폼으로 응답 전송
import analytics

responses = api.get_all_responses("form_abc")

for response in responses:
    analytics.track(
        user_id=response.get("customerKey"),
        event="Survey Completed",
        properties={
            "form_id": "form_abc",
            "rating": response.get("rating"),
            "submitted_at": response["submittedAt"]
        }
    )

모범 사례

페이지네이션

  • 대량 내보내기 시 limit=100으로 API 호출 횟수를 줄이세요
  • 테스트할 때는 작은 limit 값으로 시작하세요
  • totalPages를 확인해서 페이지네이션 종료 시점을 판단하세요

성능

  • 필요한 경우 응답을 캐시하세요
  • 대량 데이터는 한 번에 가져오지 말고 페이지네이션을 활용하세요
  • API 키당 시간당 6,000건의 속도 제한이 적용됩니다

데이터 처리

  • submittedAtnull인 경우 (미완료 응답)를 처리하세요
  • 동적 필드 이름을 고려한 처리 로직을 작성하세요
  • 필드 타입이 다양하므로 처리 전에 데이터 타입을 확인하세요

다음 단계

목차