# Guide to Tray.io Webhooks: Features and Best Practices

Tray.io webhooks let an automation workflow push data into your own systems. When a workflow reacts to a CRM change, a form submission or a scheduled sync, an HTTP Client step can send the result straight to your endpoint, so your service can act on it without polling Tray or the systems behind it.

This guide covers how Tray.io (now tray.ai) sends requests to your endpoint, how to authenticate them with a secret header, how Tray treats the responses you return, and the practices that keep a Tray receiver reliable.

## What are Tray.io webhooks?

Tray does not have an outbound webhook feature with a fixed format. Instead, a workflow calls your endpoint with the **HTTP Client connector**, the core connector Tray describes as a way to "make calls to any third-party API endpoints". The workflow builder sets every part of the request: the URL, the method, the headers and the body, usually filled from earlier steps with jsonpaths.

Tray's own **Webhook Trigger** is the opposite direction. It gives a workflow a URL that other services call, and it is not covered here.

Because you build the request, Tray adds no event type, envelope or signature. If your receiver needs to know what happened, put that in the body yourself. To authenticate the request, add a secret header whose value comes from a Tray authentication.

## Tray.io webhook features

| Feature | Details |
| --- | --- |
| Configuration | An **HTTP Client** connector step in a workflow |
| Method | Your choice, set on the step |
| Body | JSON, form-data, binary or raw, chosen with the step's body type |
| Content-Type | Set from the body type, `application/x-www-form-urlencoded` if none is chosen, or set explicitly as a header |
| Accept | Fixed to `application/json`, except for empty, `text/plain` and form-urlencoded bodies |
| Custom headers | Any header you add, typically filled from `$.auth` |
| Signature and timestamp | None |
| Event type | None. Defined by the body you build |
| Redirects | Not followed by default |
| TLS | **Reject unauthorised** is on by default |
| Response handling | From workflow version 5.0, a non-2xx response fails the step unless you list other expected status codes |
| Retries | Set by the step's error handling mode. No HTTP-specific schedule is documented |
| Source IPs | Not published on the HTTP Client page |

## What Tray.io sends

There is no Tray payload to learn. A common pattern is to give the body an `event` field and the record the workflow is acting on:

```json
{
  "event": "record.created",
  "data": {
    "id": "rec_123",
    "name": "Acme"
  }
}

```

The values come from your workflow, for example from the trigger's output or a previous connector step. Set the step's body type to JSON. If you leave it unset, Tray sends the body as `application/x-www-form-urlencoded`, which a JSON receiver will reject.

> **Inspect Tray.io requests as they arrive.** Point an HTTP Client step at a [Hookdeck Inspect](https://inspect.hookdeck.com) URL to see the exact method, headers and body your workflow produces, with no account or setup required.

## Setting up Tray.io webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`.

1. In Tray, create a **Generic Service Authentication** and store the secret in one of its fields, such as `api_key`.
2. Add an **HTTP Client** step to your workflow and select that authentication on the step.
3. Set the URL to your HTTPS endpoint and choose the method. `POST` is the simplest.
4. Add a header, for example `X-Tray-Secret`, and map its value to `$.auth.api_key`.
5. Set the body type to JSON and build the body, including a field that tells your receiver what happened.
6. Run the workflow with a manual trigger and check the step's logs.

Tray's documentation is firm about the header value: do not type authentication values into the properties panel. Reading them from `$.auth` keeps the secret obscured in Tray's logs.

To route through Hookdeck, create a Hookdeck source with the **Tray.io** type and use its URL as the step's URL. The source asks for an **API Key Header** and an **API Key**: enter the header you added and the secret's actual value. If you send it as a bearer token, the value includes the `Bearer ` prefix. The source pre-selects `POST` but leaves the method open, because the HTTP Client step lets you choose. If your step uses `GET`, `PUT`, `PATCH` or `DELETE`, add that method to the source's allowed HTTP methods as well.

## Securing Tray.io webhooks

Read the header you chose, compare its whole value with your stored secret in constant time, and reject the request if either is missing. Read the body raw, so a missing or unexpected Content-Type doesn't break the check, and parse it afterwards.

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

const HEADER_NAME = (process.env.TRAY_IO_WEBHOOK_HEADER || 'x-tray-secret').toLowerCase();
const SECRET = process.env.TRAY_IO_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();

app.post('/webhooks/tray-io', express.raw({ type: '*/*' }), (req, res) => {
  const received = req.get(HEADER_NAME);
  if (!SECRET || typeof received !== 'string' || !safeEqual(received, SECRET)) {
    return res.sendStatus(401);
  }

  let payload;
  try {
    payload = JSON.parse(req.body.toString('utf8'));
  } catch {
    // Usually the step's body type was left unset (form-urlencoded).
    return res.sendStatus(400);
  }

  // Fields are whatever your workflow builds, e.g. payload.event.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

HEADER_NAME = os.environ.get("TRAY_IO_WEBHOOK_HEADER", "X-Tray-Secret")
SECRET = os.environ.get("TRAY_IO_WEBHOOK_SECRET", "")

def verify_tray_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 there is no timestamp, so a captured request can be replayed and will still pass. Use HTTPS, leave **Reject unauthorised** switched on so the step refuses an invalid certificate, and rotate the secret in the Tray authentication and in your receiver together if it might have leaked.

> **Make Tray.io webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the secret header you configure, stores every request durably, and retries delivery to your service, so a workflow run no longer depends on your endpoint being up at that moment.

## Tray.io webhook limitations and pain points

### Your outage becomes a workflow failure

**The Problem:** When your endpoint is down or returns an error, the Tray workflow run fails or carries on without the data, depending on how the step is set up.

**Why It Happens:** From workflow version 5.0, the HTTP Client step fails on any status outside `2xx` unless you list other expected codes. What happens next depends on the step's error handling mode. **Automatic** retries some failures with backoff according to the error type, **Manual** gives the step a success and a failure path, and **Continue** ignores the failure. Tray does not document which HTTP statuses the automatic mode retries, or how many times. Workflows on versions before 5.0 treat any returned status, including `4xx` and `5xx`, as success.

**Workarounds:**

* Use Manual error handling on the step and send failures down a path that alerts someone or stores the payload for a later run.
* On older workflow versions, check the returned status with a Boolean condition step, because Tray will not raise an error for you.
* Keep your endpoint fast and return `2xx` as soon as you have accepted the request.

**How Hookdeck Can Help:** Hookdeck accepts each request as soon as it arrives and stores it, so the Tray step succeeds. Delivery to your service then follows [retries](/docs/retries) you control, and you can replay any request after a fix.

### Every workflow invents its own format

**The Problem:** Several workflows calling one endpoint send bodies with different shapes, and nothing in the request says which workflow sent it.

**Why It Happens:** The HTTP Client step sends exactly what the builder configures. Tray adds no event type or workflow identifier.

**Workarounds:**

* Agree a small envelope for every workflow, such as `event`, `source` and `data`, and validate it on arrival.
* Use a different path or header value per workflow when the receivers really are different.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) route on any field or header, and [transformations](/docs/transformations) can bring older payload shapes into line before they reach your service.

### Bursts from loops and batch syncs

**The Problem:** A workflow that loops over a list fires one request per item, which can flood your endpoint.

**Why It Happens:** Each loop iteration runs the HTTP Client step separately, as fast as the workflow runs.

**Workarounds:**

* Batch items into one request where your receiver allows it.
* Queue incoming requests on your side and process them at a steady rate.

**How Hookdeck Can Help:** Hookdeck can [set a max delivery rate](/docs/destinations#set-a-max-delivery-rate) for your destination, so a burst from Tray is absorbed and delivered at a pace your service can handle.

### The secret is only as good as the header

**The Problem:** Nothing proves a request body came from your workflow unchanged.

**Why It Happens:** There is no signature, only the static header you add.

**Workarounds:**

* Use a long random secret, sent only over HTTPS.
* Treat requests as signals, and confirm anything consequential against the system of record before acting.

**How Hookdeck Can Help:** Hookdeck's Tray.io source compares the header you name with the exact value you enter. Hookdeck answers with a `200` either way, but requests that fail the check are recorded as failed verification and never reach your service.

## Best practices

### Keep the secret in a Tray authentication

Map the header value from `$.auth`, never type it into the step. Tray obscures it in logs, and rotation becomes a single change to the authentication.

### Always set the body type

Choose JSON explicitly. The default form-urlencoded body is easy to miss and fails JSON parsing on the receiver.

### Name the event in the body

Include an `event` field, and a `source` or workflow name if several workflows share an endpoint. Your receiver should never have to guess what a request means.

### Make handlers idempotent

A rerun workflow, or a retried step, sends the same data again. Include a stable ID from the source record and deduplicate on it. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Return `2xx` as soon as the request is stored and do the work afterwards, so the Tray step completes quickly. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Tray.io sends whatever request your HTTP Client step builds: no fixed payload, no event type and no signature. Store a long random secret in a Tray authentication, send it in a header, compare it whole in constant time over HTTPS, set the body type to JSON, and decide deliberately what the step should do when your endpoint returns an error.

[Hookdeck Event Gateway](https://hookdeck.com) checks the header, keeps every request, and retries and replays delivery to your service, so a short outage on your side does not fail the workflow or lose its data.

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