# Guide to Hightouch Webhooks: Features and Best Practices

Hightouch is a data activation platform, often called reverse ETL. It runs a query against your warehouse, works out which rows have been added, changed or removed since the last run, and syncs those changes to other tools. The HTTP Request destination points a sync at any API, which makes it the way to get warehouse changes into your own service as webhooks.

This guide covers how the HTTP Request destination decides what to send, what the requests look like, how to authenticate them with a header you add yourself, how Hightouch handles errors, and the practices that keep a Hightouch receiver reliable in production.

## What are Hightouch webhooks?

Hightouch's webhooks are requests made by a sync to an **HTTP Request** destination. The older **Webhook** destination was deprecated on September 1, 2022, and Hightouch points users to HTTP Request instead.

A sync watches a model's query results for three kinds of change: rows added, rows changed and rows removed. Each of these is a trigger, and you configure each one separately, with its own HTTP method, endpoint and payload. A typical setup creates a record on rows added, updates it on rows changed and deletes it on rows removed. A valid sync needs at least one trigger.

Nothing in the request says which trigger sent it. Hightouch writes no event name into the body and adds no event header, so the trigger only shows through the method, URL or payload you chose for it. There is no Hightouch event list.

Hightouch does not sign requests either. The destination can add headers to every request, and Hightouch's docs note that these often carry access tokens. A secret header you add yourself, and check on arrival, is how you authenticate Hightouch requests.

## Hightouch webhook features

| Feature | Details |
| --- | --- |
| Configuration | **Destinations > Add destination > HTTP Request**, then a sync on a model |
| Triggers | Rows added, rows changed and rows removed, each configured separately |
| HTTP methods | `GET`, `POST`, `PUT`, `PATCH` and `DELETE`, chosen per trigger |
| Endpoint | Base URL on the destination, plus a static or Liquid-templated path per trigger |
| Payload | JSON (Liquid editor, one column, or a column mapping), XML, URL-encoded form, or empty |
| Batching | Optional. Several rows per request, default batch size 100 |
| Authentication | Headers on the destination (tick **Secret** to encrypt and obscure them), or OAuth. No signature |
| Errors | Timeouts and any `4xx` or `5xx` response, retried indefinitely |
| Rate limits | Defaults of 1000 requests per second or 100 concurrent requests, adjustable |
| Certificates | Optional custom CA, SSL or TLS certificate |
| Source IPs | Not published on the destination page |

## What a request looks like

You write the payload. In the JSON editor, `{{row.column_name}}` inserts a value from the model, and Liquid filters reshape it. A trigger for rows added might send:

```json
{
  "action": "row_added",
  "email": "{{row.email}}",
  "plan": "{{row.plan}}",
  "sync_run_id": "{{context.sync_run_id}}"
}

```

The `action` key above is your own convention, not something Hightouch adds. Putting it in each trigger's template, or giving each trigger its own method or path, is what lets one endpoint tell the triggers apart. The `context` variable also exposes `context.model_id`, `context.model_name` and `context.sync_id`, but only if you put them in the template.

Instead of a template, you can send one model column as the whole body, or map several columns to fields. With batching on, a column-based payload is a JSON array of rows, and a JSON editor template gets `rows` instead of `row` and loops over them to build whatever wrapper you want. Either way, a batched request carries many rows.

> **Inspect Hightouch requests as they arrive.** Point the destination at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the exact method, headers and body each trigger sends, with no account or setup required.

## Setting up Hightouch webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`, and decide which header it will travel in.

1. In Hightouch, go to **Destinations**, click **Add destination**, select **HTTP Request** and click **Continue**.
2. Enter your endpoint as the **base URL**.
3. Under **HTTP headers**, add your secret header, such as `X-Hightouch-Secret`, tick **Secret** so Hightouch encrypts it and hides it in the UI, and enter the value. Whatever you type is sent as-is, so include `Bearer ` if you use `Authorization` and want a bearer token.
4. Create a sync from your model to the destination, and choose its triggers.
5. For each trigger, set the method, the endpoint path and the payload. Turn on batching if your endpoint accepts arrays.
6. Configure rate limits, error handling and the initial sync behaviour. Choosing to skip existing rows avoids a backfill of every row on the first run.

Use the secret header rather than OAuth. With OAuth, Hightouch fetches access tokens from your provider and refreshes them, so there is no fixed value for a receiver to compare.

To put Hookdeck in front of your service, create a Hookdeck source with the **Hightouch** type and use its URL as the base URL. The source asks for an **API Key Header** and an **API Key**: enter the same name and the whole value you typed in Hightouch. Header names match case-insensitively, but the value must match exactly, prefix included. The source pre-selects `POST`. If your triggers use `PUT`, `PATCH`, `DELETE` or `GET`, allow those methods on the source too, or Hookdeck will not accept those requests.

For local development, `hookdeck listen 3000 hightouch --path /webhooks/hightouch` gives you a public URL that forwards to your machine.

## Securing Hightouch webhooks

Read the header you chose and compare its whole value with your stored secret in constant time. Reject the request if the header is missing or the secret isn't configured.

```javascript
const crypto = require('crypto');
const express = require('express');

const HEADER_NAME = process.env.HIGHTOUCH_WEBHOOK_HEADER || 'x-hightouch-secret';
const SECRET = process.env.HIGHTOUCH_WEBHOOK_SECRET;

function safeEqual(received, expected) {
  const a = Buffer.from(received, 'utf8');
  const b = Buffer.from(expected, 'utf8');
  // timingSafeEqual throws on unequal lengths, so check first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// Each trigger chooses its own method, so accept all of them on the route.
app.all('/webhooks/hightouch', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME); // case-insensitive lookup
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
  let payload = null;
  if (raw && req.is('application/json')) {
    try {
      payload = JSON.parse(raw);
    } catch {
      return res.sendStatus(400);
    }
  }

  const rows = Array.isArray(payload) ? payload : [payload]; // batched or single
  // Route on req.method, the path, or the action key in your template.

  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("HIGHTOUCH_WEBHOOK_HEADER", "X-Hightouch-Secret")
SECRET = os.environ.get("HIGHTOUCH_WEBHOOK_SECRET", "")

def verify_hightouch_request(headers) -> bool:
    if not SECRET:
        return False  # never accept when unconfigured
    received = headers.get(HEADER_NAME)  # Starlette and Flask headers are case-insensitive
    if not received:
        return False
    # Compare bytes: compare_digest raises TypeError on a non-ASCII str.
    return hmac.compare_digest(received.encode("utf-8"), SECRET.encode("utf-8"))

```

A static header proves the sender knows the secret. It does not cover the body and carries no timestamp, so a modified or replayed request still passes. Serve the endpoint over HTTPS, use a long random value marked **Secret** in Hightouch, and rotate it if it might have leaked.

> **Make Hightouch syncs production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the secret header, absorbs each sync run at full speed, and delivers rows to your service at a rate you set.

## Hightouch webhook limitations and pain points

### A secret header is all you get

**The Problem:** Nothing in a Hightouch request proves the body is unchanged or that the request is new.

**Why It Happens:** The destination's authentication options are headers you add and OAuth. Neither is computed over the body, and Hightouch publishes no egress IPs on the destination page to allowlist.

**Workarounds:**

* Use a long random secret, mark it **Secret**, and send it over HTTPS only.
* Make handlers idempotent so a replayed request does no harm.

**How Hookdeck Can Help:** Hookdeck's Hightouch source compares the header you name with the exact value you enter. Hookdeck still answers with a `200`, but a request with a missing or wrong value is recorded as failed verification and never reaches your destination.

### Errors are retried forever

**The Problem:** A bad row or a bug in your handler produces a request that fails on every run, and keeps failing.

**Why It Happens:** Hightouch treats timeouts and every `4xx` and `5xx` response as errors and retries them indefinitely until they succeed, either immediately within the run or on later sync runs. A `400` for a row you will never accept is retried as faithfully as a `503`.

**Workarounds:**

* Return `200` for rows you deliberately skip, and log them, rather than a `4xx`.
* Set up Hightouch's alerts for row and sync errors.
* Choose "retry on next sync run" for downstream outages, so a run isn't spent hammering a service that is down.

**How Hookdeck Can Help:** Hookdeck accepts the request and stores it, so Hightouch records success, and failures between Hookdeck and your service become [retries](/docs/retries) you control and can stop. [Issues](/docs/issues) alert you, and you can replay requests after a fix.

### Sync runs arrive as bursts

**The Problem:** A run that finds many changed rows sends them as fast as the rate limits allow, which can overload a service sized for steady traffic.

**Why It Happens:** Hightouch defaults to 1000 requests per second or 100 concurrent requests, and a first run that backfills existing rows can send every row in the model.

**Workarounds:**

* Lower the rate and concurrency limits on the sync to what your endpoint can sustain.
* Enable batching so each request carries up to 100 rows.
* Skip existing rows on the first run unless you need a backfill.

**How Hookdeck Can Help:** Hookdeck accepts the run at Hightouch's speed and delivers to your service at a [max delivery rate](/docs/destinations#set-a-max-delivery-rate) you set, so the sync finishes quickly and your service is not overloaded.

### The request doesn't name its trigger

**The Problem:** A handler can't tell an added row from a changed one unless you designed the sync so it can.

**Why It Happens:** Each trigger has its own method, path and payload, but Hightouch writes no trigger name into the request.

**Workarounds:**

* Give each trigger a distinct method or path, or put an explicit key in each template.
* Include a stable row identifier from your model in every payload.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can route requests by method, path or a body field, so rows added and rows removed can go to different destinations.

## Best practices

### Design the payload as an API contract

You own the request format, so write it down: one method or path per trigger, a stable ID for each row, and an explicit action key. Change it the way you would change an API, since every receiver depends on it.

### Deduplicate on row and run

Indefinite retries and immediate in-run retries mean the same row can arrive more than once. Store a key built from the row's ID, the trigger and `context.sync_run_id`, and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Handle batches row by row

With batching on, one request carries many rows. Process each row independently, and decide what one bad row should do to the rest, since your response code answers for the whole request.

### Acknowledge fast and process asynchronously

Authenticate, store the request, return `200`, and do the work from a queue. A slow handler slows the whole sync run. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Hightouch's HTTP Request destination sends the rows a sync finds added, changed or removed, in a format and with methods you design, with no signature and no trigger name. Add a secret header and compare its whole value in constant time over HTTPS, give each trigger something your handler can route on, deduplicate on row and run, and avoid error responses for rows you will never accept, because Hightouch retries them forever.

[Hookdeck Event Gateway](https://hookdeck.com) checks the secret header, absorbs each sync run at full speed, and holds every request until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Hightouch webhooks reliably in minutes.