# 커넥티드 콘텐츠 (Connected Content) 작성하기

메시지를 보내는 그 순간, 외부 API에서 **실시간 데이터**를 가져와 본문에 끼워 넣는 기능입니다. 잔여 포인트, 오늘의 쿠폰, 개인화 추천 상품처럼 Hackle에 저장돼 있지 않은 정보를 메시지에 활용할 수 있습니다.

### 비개발자를 위한 커넥티드 콘텐츠 작성 방법

{% hint style="info" icon="lightbulb" %}
코드를 직접 쓸 필요는 없습니다. 메시지 편집기의 **`{...}` 개인화 변수 추가 버튼**을 누르면, 폼만 채우는 방식으로 `{% connected_content %}` 태그가 자동으로 만들어집니다.
{% endhint %}

**시작 전 준비물 (개발팀에서 받아 두세요)**

1. 호출할 API 주소(`https://`로 시작)
2. 요청 방식(GET 또는 POST)
3. 응답 예시(JSON) - 어떤 필드를 메시지에 쓸 수 있는지 확인용

먼저 메시지 편집기에서 `{...}` 버튼 클릭 → "커넥티드 콘텐츠" 탭 선택하세요

모달이 열리면 상단에서 **속성** / **커넥티드 콘텐츠** 중 "커넥티드 콘텐츠 — 외부 API에서 실시간 데이터"를 선택합니다.

<figure><img src="/files/T5ZBpMbl3Jm5CgZzQo6Q" alt=""><figcaption></figcaption></figure>

**STEP 1. API URL 입력 후 `API 테스트` 클릭**

**API URL**을 입력합니다. 인증 헤더나 POST 본문이 필요하면 **요청 옵션**을 펼쳐 입력합니다. URL에는 `{{user_properties["id"]}}` 같은 개인화 변수도 그대로 끼워 넣을 수 있습니다. 그다음 **`API 테스트`** 버튼을 눌러 실제 응답이 잘 오는지, 어떤 필드가 포함되어 있는지 미리 확인합니다.

<figure><img src="/files/c77kgkrhkXIkKijb2EUy" alt=""><figcaption></figcaption></figure>

**STEP 2. 메시지에 넣을 값 선택 + 변수명 입력**

응답 결과에서 메시지에 노출할 필드(예: `code`, `discount`)를 체크합니다. 그리고 \*\*응답 변수명(`:save`)\*\*에 사용할 이름(예: `product`)을 입력합니다. 이 이름이 곧 본문에서 값을 꺼내 쓸 때 사용할 변수가 됩니다.

<figure><img src="/files/VH5lddSjCjIdFKNnzLN6" alt=""><figcaption></figcaption></figure>

**STEP 3. `저장하기` 클릭 → 본문에서 변수 클릭으로 삽입**

`저장하기`를 누르면 모달이 만든 변수가 **"사용할 수 있는 변수" 목록**에 추가됩니다. 본문 원하는 위치에 커서를 두고 해당 변수를 클릭하면 `{{coupon.code}}`처럼 자동 삽입됩니다.

```liquid
{% connected_content https://dummyjson.com/products/1 :save product %}

찜하신 {{product.title}} 재고 {{product.stock}}개 남았어요!
지금 {{product.price}}원에 만나보세요
```

<figure><img src="/files/IEVFyGx3RQTnrBKyQXKc" alt=""><figcaption></figcaption></figure>

{% hint style="info" %}

#### 안전하게 쓰는 팁

응답이 비어 올 때를 대비해 **기본 문구**를 함께 넣어 두세요.&#x20;
{% endhint %}

### 기본 문법

```liquid
{% connected_content <URL>
    [:method <METHOD>]
    [:body <BODY>]
    [:headers <HEADERS_JSON>]
    [:save <VAR_NAME>] %}
```

* `<URL>` (필수): 호출할 외부 엔드포인트의 절대 URL. `https://`만 허용됩니다.
* `:method` : HTTP 메서드. `GET` / `POST`만 지원, 기본값 `GET`.
* `:body` : POST 요청 본문. JSON 객체 문자열 형태로 작성해야 합니다.
* `:headers` : 요청 헤더. JSON 객체 문자열 형태로 작성해야 하며, 값은 모두 문자열이어야 합니다. 지정하지 않으면 `Content-Type: application/json`이 기본 적용됩니다.
* `:save` : 응답 JSON을 담을 변수 이름. 이후 `{{변수.필드}}` 형태로 메시지에서 꺼내 쓸 수 있습니다. 지정하지 않으면 응답을 사용할 수 없습니다.

> URL, body, headers의 값에 `{{user_properties["name"] | default: "고객님"}}` , `{{event_properties["campaign_id"] | default: "1"}}` 같은 개인화 변수를 자유롭게 사용 할 수 있습니다.

<figure><img src="/files/BndFkoqFdL0LqzV6Zvhj" alt=""><figcaption></figcaption></figure>

### 파라미터

#### URL

* 반드시 절대 URL이며 `https://` 스킴만 허용합니다.
* 공인 IP로 해석되는 최종 URL만 호출할 수 있습니다.

#### :method

* 지원: `GET`, `POST` , 기본값 `GET`
* 그 외 메서드(`PUT`, `DELETE`, `PATCH` 등)는 거부되고 태그가 통째로 스킵됩니다.

#### :body

* `:method POST`일 때 사용합니다. `GET`에서는 무시됩니다.
* 인라인 JSON을 그대로 적을 수 있습니다. 예: `:body {"foo":"bar"}`

#### :headers

* 반드시 JSON 객체 문자열이어야 하고 모든 값은 문자열이어야 합니다.
  * 좋은 예: `:headers {"Authorization":"Bearer abc","X-Trace":"123"}`
  * 잘못된 예: `:headers {"Retry": 3}` (값이 숫자) → 검증 실패로 태그 스킵
* `Content-Type: application/json` 타입만을 지원하며, 명시하지 않아도 기본값으로 자동 적용됩니다.

#### :save

* 응답 JSON 객체를 이 이름의 변수로 컨텍스트에 저장합니다.
* 같은 템플릿 안의 이후 태그/Output 노드 모두에서 참조 가능합니다. 예를 들어 첫 번째 `connected_content`의 결과를 두 번째 `connected_content`의 URL이나 body에 다시 사용할 수 있습니다.
* 응답이 JSON 객체가 아니거나 비어 있으면 빈 값으로 저장됩니다(렌더링 시 빈 문자열).

***

### 예제

#### 1. 가장 단순한 GET 호출

```liquid
{% connected_content https://api.example.com/products/123 :save product %}
{{product.name}} {{product.price}}원
```

응답이 `{"name":"운동화","price":59000}`이라면 → `운동화 59000원`

#### 2. URL에 사용자/이벤트 변수 끼워 넣기

```liquid
{% connected_content https://api.example.com/orders/{{event_properties["order_id"]}} :save order %}
주문 상태: {{order.status}}
```

`event_properties.order_id = "ABC-123"`이면 실제 호출 URL은 `https://api.example.com/orders/ABC-123`이 됩니다.

#### 3. POST + JSON body

```liquid
{% connected_content https://api.example.com/recommend :method POST
    :body {"user_id":"{{user_properties["id"]}}","limit":3}
    :save rec %}
추천 상품: {{rec.items[0].name}}
```

#### 4. 인증 헤더가 필요한 호출

```liquid
{% connected_content https://api.example.com/me
    :headers {"Authorization":"Bearer {{api_properties.token}}"}
    :save me %}
{{me.nickname}}님 안녕하세요
```

#### 5. 중첩된 JSON 응답에서 값 꺼내기

```liquid
{% connected_content https://api.example.com/products/456 :save data %}
{{data.product.name}} by {{data.product.brand}}
```

응답 `{"product":{"name":"티셔츠","brand":"Nike"}}` → `티셔츠 by Nike`

#### 6. 태그 체이닝 (앞 호출 결과를 다음 호출에 사용)

```liquid
{% connected_content https://api.example.com/me :save me %}
{% connected_content https://api.example.com/orders/{{me.id}} :save orders %}
{{me.id}}님의 주문 {{orders.total}}건
```

#### 7. POST + form-encoded 본문 + 커스텀 헤더

```liquid
{% connected_content https://api.example.com/post
    :method POST
    :headers {"X-Trace":"abc123","X-Src":"hackle"}
    :body {"a":1,"b"=3}
    :save r %}
{{r.ok}}
```

***

### 응답 처리 규칙

| 응답 형태                              | 결과                                                                       |
| ---------------------------------- | ------------------------------------------------------------------------ |
| `200 OK` + JSON 객체                 | JSON을 파싱하여 `:save` 변수에 매핑. 필드는 `{{var.field}}` 또는 `{{var["field"]}}`로 접근 |
| `200 OK` + JSON 배열 또는 원시값          | JSON 객체가 아니므로 빈 값으로 처리                                                   |
| `200 OK` + 빈 본문                    | 빈 값으로 처리                                                                 |
| 200이 아닌 모든 상태 코드 (4xx, 5xx, 3xx 등) | 빈 값으로 처리 (리다이렉트 따라가지 않음)                                                 |
| 응답 본문이 1MB 초과                      | 빈 값으로 처리                                                                 |
| 네트워크 오류, 타임아웃                      | 빈 값으로 처리                                                                 |

> 빈 값(empty) 으로 처리되었다는 것은 `:save`로 지정한 변수에 빈 맵이 저장되었다는 뜻입니다. `{{var.anything}}`은 빈 문자열로 렌더링되며, 이로 인해 메시지 발송 자체가 실패하지는 않습니다.

***

### 제약 사항과 보안 정책

#### 보호 한도

| 항목                               | 한도                                   |
| -------------------------------- | ------------------------------------ |
| URL 스킴                           | `https://`만 허용                       |
| 호스트                              | 공인 IP로 해석되는 호스트만 허용 (사내망 / 사설 대역 차단) |
| 리다이렉트                            | **따라가지 않음** (최종 URL을 직접 지정해야 함)      |
| 응답 본문 크기                         | **최대 1MB** (초과 시 빈 값 처리)             |
| 단일 호출 타임아웃                       | **2초**                               |
| 한 메시지(Liquid render) 내부 누적 호출 시간 | **총 2초** (한도 초과 후 호출은 HTTP 호출 없이 스킵) |
| HTTP 메서드                         | `GET`, `POST`만                       |
| 헤더 값 형식                          | JSON 객체 문자열, 값은 모두 문자열               |

***

### 실패 시 동작

Connected Content는 메시지 발송을 막지 않습니다. 모든 실패 케이스는 "default 값으로 대체" 됩니다.

**대표 케이스:**

* URL이 비어 있거나 플래그-값 쌍이 맞지 않는 경우 → 태그 전체 스킵
* `:method`에 `PUT` 같은 미지원 메서드를 지정 → 태그 스킵
* `:headers`가 유효한 JSON 객체 문자열이 아닌 경우 → 태그 스킵
* URL 검증 실패 (HTTPS 아님, 사설 IP 등) → 빈 응답
* 네트워크 오류, 200 외 응답, 본문 1MB 초과, JSON 객체 아님 → 빈 응답
* 한 메시지 내 누적 호출 시간 2초 초과 이후의 호출 → HTTP 호출 없이 스킵, 빈 응답

빈 응답이 저장된 변수를 메시지에서 사용해도 빈 문자열로 렌더링됩니다. 따라서 응답이 비어 있을 때를 대비해 다음과 같은 방어 코드를 곁들이는 것을 권장합니다.

```
{% connected_content https://api.example.com/coupon :save coupon %}
{% if coupon.code %}
오늘의 쿠폰: {{coupon.code}}
{% else %}
지금 바로 확인해 보세요!
{% endif %}
```

```
{% connected_content https://api.example.com/user :save user %}
{{ user.name | default: "고객" }}님 안녕하세요! ...
```

***

### 자주 묻는 질문

<details>

<summary><strong>Q. 한 메시지에 <code>connected_content</code>를 여러 번 써도 되나요?</strong></summary>

가능합니다. 단, 한 메시지의 모든 `connected_content` 호출은 누적 2초의 시간 예산을 공유합니다. 첫 번째 호출이 1.8초 걸렸다면 두 번째 호출은 0.2초 안에 끝나야 하고, 그 사이에 한도를 초과하면 이후 호출은 네트워크 호출 없이 즉시 스킵됩니다. 외부 API의 평균 응답시간이 충분히 짧을 때만 다중 호출을 사용하세요.

</details>

<details>

<summary><strong>Q. 응답을 받기 전에 발송 시간이 오래 걸리진 않나요?</strong></summary>

각 호출은 최대 2초 안에 종료됩니다. 또한 한 메시지의 누적 한도가 2초이므로 발송 지연이 무한정 누적되지 않습니다.

</details>

<details>

<summary><strong>Q. 응답이 JSON 배열일 때는 어떻게 접근하나요?</strong></summary>

최상위가 배열이면 객체로 인식되지 않아 빈 값이 됩니다. API 측에서 `{"items":[...]}`처럼 객체로 감싼 형태로 내려주도록 하거나, 래핑 엔드포인트를 따로 두세요.

</details>

<details>

<summary><strong>Q. JSON이 아닌 응답(예: 단순 텍스트, HTML)도 사용할 수 있나요?</strong></summary>

지원하지 않습니다. JSON 객체로 파싱되지 않으면 빈 값으로 처리됩니다.

</details>

<details>

<summary><strong>Q. 응답을 메시지에 직접 노출하지 않고 조건 분기에만 쓰고 싶어요.</strong></summary>

`:save` 변수를 `{% if %}` 등에서 그대로 쓸 수 있고, 메시지 본문에 출력할 필요는 없습니다. 위의 "실패 시 동작" 섹션의 예시를 참고하세요.

</details>

<details>

<summary><strong>Q. 키에 한글, 공백, 점이 들어 있는 응답 필드는 어떻게 꺼내나요?</strong></summary>

`{{var["키 이름"]}}` 형태의 bracket subscript를 사용하세요. 작은따옴표(`'`)도 사용 가능합니다.

</details>


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.hackle.io/crm-marketing/message-personalization/connected-content.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
