> 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.

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

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

{% hint style="success" %}
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 %}

{% hint style="info" %}
**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
   {% endhint %}

{% stepper %}
{% step %}

### Select Connected Content

Click the `{...}` button in the message editor. Select the **Connected Content** tab.

At the top of the modal, choose between **Property** and **Connected Content**. Select **Connected Content — real-time data from an external API**.

<figure><img src="/files/NwvoRy3AdYcjaXqfe9A5" alt=""><figcaption></figcaption></figure>
{% endstep %}

{% step %}

### Enter and test the API URL

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>
{% endstep %}

{% step %}

### Choose the values and variable name to insert into the message

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>
{% endstep %}

{% step %}

### Insert the saved variable into 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.
```

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

<figure><img src="/files/lD3ioqvP7byTWPllnn0q" alt=""><figcaption></figcaption></figure>
{% endstep %}
{% endstepper %}

### Basic Syntax

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

<table><thead><tr><th width="121.3203125">Item</th><th width="494.12109375">Description</th><th>Required</th></tr></thead><tbody><tr><td><code>&#x3C;URL></code></td><td><ul><li>The absolute URL of the API to call.</li><li>Only the <code>https://</code> scheme can be used.</li><li>Only final URLs that resolve to a public IP can be called.</li></ul></td><td><strong>Required</strong></td></tr><tr><td><code>:method</code></td><td><ul><li>The HTTP method.</li><li><code>GET</code> and <code>POST</code> are supported.</li><li>The default is <code>GET</code>.</li><li><code>PUT</code>, <code>DELETE</code>, <code>PATCH</code>, and the like skip the entire tag.</li></ul></td><td>Optional</td></tr><tr><td><code>:body</code></td><td><ul><li>The POST request body.</li><li>Used only when <code>:method POST</code>.</li><li>Ignored for GET requests.</li><li>Enter a JSON object string. Example: <code>:body {"foo":"bar"}</code></li></ul></td><td>Optional</td></tr><tr><td><code>:headers</code></td><td><ul><li>The request headers.</li><li>Enter a JSON object whose values are all strings.</li><li>Example: <code>:headers {"Authorization":"Bearer abc","X-Trace":"123"}</code></li><li>If you enter a numeric value such as <code>:headers {"Retry": 3}</code>, validation fails and the tag is skipped.</li><li><code>Content-Type: application/json</code> is applied by default.</li></ul></td><td>Optional</td></tr><tr><td><code>:save</code></td><td><ul><li>The name of the variable that will store the response JSON object.</li><li>You can then use it as <code>{{variable.field}}</code>.</li><li>It can also be referenced by subsequent tags and Output nodes in the same template.</li><li>If the response is not an object or is empty, an empty value is stored.</li></ul></td><td>Optional</td></tr></tbody></table>

{% hint style="success" %}
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.
{% endhint %}

### Response Handling Rules

| Response form                                         | Result                                                                                                                                                            |
| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200 OK` + JSON object                                | <p>Parses the JSON and maps it to the <code>:save</code> variable.</p><p>Fields are accessed via <code>{{var.field}}</code> or <code>{{var\["field"]}}</code></p> |
| `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                                                                                                                                         |

{% hint style="warning" %}
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.
{% endhint %}

### Security Policy

<table><thead><tr><th width="335.4296875">Item</th><th>Limit</th></tr></thead><tbody><tr><td>URL scheme</td><td>Only <code>https://</code> allowed</td></tr><tr><td>Host</td><td>Only hosts that resolve to a public IP are allowed<br>Internal networks / private ranges are blocked.</td></tr><tr><td>Redirects</td><td><strong>Not followed</strong><br>You must specify the final URL directly.</td></tr><tr><td>Response body size</td><td><strong>Up to 1MB</strong><br>Treated as an empty value if exceeded.</td></tr><tr><td>Single call timeout</td><td><strong>2 seconds</strong></td></tr><tr><td>Cumulative call time within one message (Liquid render)</td><td><strong>2 seconds total</strong><br>Calls after the limit is exceeded are skipped without an HTTP call.</td></tr><tr><td>HTTP methods</td><td><code>GET</code>, <code>POST</code> only</td></tr><tr><td>Header value format</td><td>JSON object string, all values must be strings</td></tr></tbody></table>

### 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" }}! ...
```

***

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

***


---

# 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, and the optional `goal` query parameter:

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

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
