> For the complete documentation index, see [llms.txt](https://docs.hackle.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.hackle.io/en/crm-marketing/message-personalization/connected-content.md).

# Writing Connected Content

This is a feature that pulls **real-time data** from an external API at the very moment a message is sent and inserts it into the body. You can use information that is not stored in Hackle, such as remaining points, today's coupon, and personalized recommended products, in your messages.

### How to write Connected Content for non-developers

{% hint style="info" icon="lightbulb" %}
You do not need to write code directly. When you press the **`{...}` Add personalization variable button** in the message editor, the `{% connected_content %}` tag is automatically generated just by filling out a form.
{% endhint %}

**Before you start (get these from your development team)**

1. The API address to call (starts with `https://`)
2. The request method (GET or POST)
3. A response example (JSON) - to check which fields can be used in the message

First, in the message editor, click the `{...}` button → select the "Connected Content" tab.

When the modal opens, select "Connected Content — real-time data from an external API" from **Property** / **Connected Content** at the top.

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

**STEP 1. Enter the API URL and click `Test API`**

Enter the **API URL**. If you need authentication headers or a POST body, expand **Request Options** and enter them. You can insert personalization variables such as `{{user_properties["id"]}}` directly into the URL as well. Then, press the **`Test API`** button to check in advance whether the actual response comes back correctly and which fields are included.

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

**STEP 2. Select the values to insert into the message + enter a variable name**

From the response result, check the fields to display in the message (e.g., `code`, `discount`). Then, enter the name (e.g., `product`) to use for the \*\*response variable name (`:save`)\*\*. This name becomes the variable you will use to pull values out in the body.

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

**STEP 3. Click `Save` → insert by clicking the variable in the body**

When you press `Save`, the variable created by the modal is added to the **"Available variables" list**. Place the cursor at the desired position in the body and click that variable, and it will be inserted automatically like `{{coupon.code}}`.

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

The {{product.title}} you saved has {{product.stock}} left in stock!
Discover it now for {{product.price}} won.
```

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

{% hint style="info" %}

#### Tips for using it safely

In case the response comes back empty, include a **fallback text** as well.
{% endhint %}

### Basic Syntax

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

* `<URL>` (required): The absolute URL of the external endpoint to call. Only `https://` is allowed.
* `:method` : The HTTP method. Only `GET` / `POST` are supported, default `GET`.
* `:body` : The POST request body. Must be written as a JSON object string.
* `:headers` : The request headers. Must be written as a JSON object string, and all values must be strings. If not specified, `Content-Type: application/json` is applied by default.
* `:save` : The name of the variable to hold the response JSON. You can then pull it out in the message in the form `{{variable.field}}`. If not specified, the response cannot be used.

> You can freely use personalization variables such as `{{user_properties["name"] | default: "Customer"}}` and `{{event_properties["campaign_id"] | default: "1"}}` in the values of the URL, body, and headers.

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

### Parameters

#### URL

* Must be an absolute URL, and only the `https://` scheme is allowed.
* Only final URLs that resolve to a public IP can be called.

#### :method

* Supported: `GET`, `POST`, default `GET`
* Other methods (`PUT`, `DELETE`, `PATCH`, etc.) are rejected, and the entire tag is skipped.

#### :body

* Used when `:method POST`. It is ignored for `GET`.
* You can write inline JSON directly. Example: `:body {"foo":"bar"}`

#### :headers

* Must be a JSON object string, and all values must be strings.
  * Good example: `:headers {"Authorization":"Bearer abc","X-Trace":"123"}`
  * Bad example: `:headers {"Retry": 3}` (value is a number) → validation fails, tag is skipped
* Only the `Content-Type: application/json` type is supported, and it is applied automatically as the default even if not specified.

#### :save

* Stores the response JSON object in the context as a variable with this name.
* It can be referenced by all subsequent tags / Output nodes within the same template. For example, you can reuse the result of the first `connected_content` in the URL or body of the second `connected_content`.
* If the response is not a JSON object or is empty, it is stored as an empty value (renders as an empty string).

***

### Examples

#### 1. The simplest GET call

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

If the response is `{"name":"Sneakers","price":59000}` → `Sneakers 59000 won`

#### 2. Inserting user/event variables into the URL

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

If `event_properties.order_id = "ABC-123"`, the actual call URL becomes `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 %}
Recommended product: {{rec.items[0].name}}
```

#### 4. A call that requires an authentication header

```liquid
{% connected_content https://api.example.com/me
    :headers {"Authorization":"Bearer {{api_properties.token}}"}
    :save me %}
Hello, {{me.nickname}}
```

#### 5. Extracting a value from a nested JSON response

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

Response `{"product":{"name":"T-shirt","brand":"Nike"}}` → `T-shirt by Nike`

#### 6. Tag chaining (using the result of a previous call in the next call)

```liquid
{% connected_content https://api.example.com/me :save me %}
{% connected_content https://api.example.com/orders/{{me.id}} :save orders %}
{{me.id}} has {{orders.total}} orders
```

#### 7. POST + form-encoded body + custom headers

```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}}
```

***

### Response Handling Rules

| Response form                                         | Result                                                                                                             |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `200 OK` + JSON object                                | Parses the JSON and maps it to the `:save` variable. Fields are accessed via `{{var.field}}` or `{{var["field"]}}` |
| `200 OK` + JSON array or primitive value              | Not a JSON object, so treated as an empty value                                                                    |
| `200 OK` + empty body                                 | Treated as an empty value                                                                                          |
| All status codes other than 200 (4xx, 5xx, 3xx, etc.) | Treated as an empty value (redirects are not followed)                                                             |
| Response body exceeds 1MB                             | Treated as an empty value                                                                                          |
| Network error, timeout                                | Treated as an empty value                                                                                          |

> Being treated as an empty value means that an empty map is stored in the variable specified by `:save`. `{{var.anything}}` renders as an empty string, and this does not cause the message send itself to fail.

***

### Constraints and Security Policy

#### Protective Limits

| Item                                                    | Limit                                                                                      |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| URL scheme                                              | Only `https://` allowed                                                                    |
| Host                                                    | Only hosts that resolve to a public IP allowed (internal network / private ranges blocked) |
| Redirects                                               | **Not followed** (you must specify the final URL directly)                                 |
| Response body size                                      | **Up to 1MB** (treated as empty value if exceeded)                                         |
| Single call timeout                                     | **2 seconds**                                                                              |
| Cumulative call time within one message (Liquid render) | **2 seconds total** (calls after the limit is exceeded are skipped without an HTTP call)   |
| HTTP methods                                            | `GET`, `POST` only                                                                         |
| Header value format                                     | JSON object string, all values must be strings                                             |

***

### Behavior on Failure

Connected Content does not block message sending. All failure cases are "replaced with the default value."

**Representative cases:**

* URL is empty or a flag-value pair does not match → entire tag is skipped
* An unsupported method such as `PUT` is specified in `:method` → tag is skipped
* `:headers` is not a valid JSON object string → tag is skipped
* URL validation fails (not HTTPS, private IP, etc.) → empty response
* Network error, non-200 response, body exceeds 1MB, not a JSON object → empty response
* Calls after the cumulative call time within one message exceeds 2 seconds → skipped without an HTTP call, empty response

Even if you use a variable in which an empty response is stored in the message, it renders as an empty string. Therefore, we recommend adding defensive code like the following in case the response is empty.

```
{% connected_content https://api.example.com/coupon :save coupon %}
{% if coupon.code %}
Today's coupon: {{coupon.code}}
{% else %}
Check it out right now!
{% endif %}
```

```
{% connected_content https://api.example.com/user :save user %}
Hello, {{ user.name | default: "Customer" }}! ...
```

***

### FAQ

<details>

<summary><strong>Q. Can I use <code>connected_content</code> multiple times in one message?</strong></summary>

Yes. However, all `connected_content` calls in one message share a cumulative time budget of 2 seconds. If the first call took 1.8 seconds, the second call must finish within 0.2 seconds, and if the limit is exceeded in between, subsequent calls are immediately skipped without a network call. Use multiple calls only when the average response time of the external API is sufficiently short.

</details>

<details>

<summary><strong>Q. Won't it take a long time to send while waiting for the response?</strong></summary>

Each call terminates within a maximum of 2 seconds. In addition, since the cumulative limit for one message is 2 seconds, send delays do not accumulate indefinitely.

</details>

<details>

<summary><strong>Q. How do I access the response when it is a JSON array?</strong></summary>

If the top level is an array, it is not recognized as an object and becomes an empty value. Have the API side return it wrapped in an object, such as `{"items":[...]}`, or set up a separate wrapping endpoint.

</details>

<details>

<summary><strong>Q. Can I use non-JSON responses (e.g., plain text, HTML)?</strong></summary>

Not supported. If it cannot be parsed as a JSON object, it is treated as an empty value.

</details>

<details>

<summary><strong>Q. I want to use the response only for conditional branching without displaying it directly in the message.</strong></summary>

You can use the `:save` variable directly in `{% if %}` and the like, and you do not need to output it in the message body. Refer to the examples in the "Behavior on Failure" section above.

</details>

<details>

<summary><strong>Q. How do I extract a response field whose key contains Korean characters, spaces, or dots?</strong></summary>

Use bracket subscript in the form `{{var["key name"]}}`. Single quotes (`'`) can also be used.

</details>


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## 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/en/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.
