# Tray.io Webhooks

Tray.io (now tray.ai) does not have a fixed webhook format. Your endpoint is called by the
**HTTP Client connector** in a workflow, so the method, headers and body are whatever the workflow
builder configures. Requests are **not signed**. You authenticate them with a secret header that
you add to the HTTP Client step's headers, with the value taken from a Generic Service
Authentication (`$.auth`), and your endpoint compares that header with the value it has stored.

**A static shared credential authenticates the sender, not the body.** There is no signature and no
timestamp, so there is no replay protection and a tampered body can't be detected. Use HTTPS only,
and rotate the credential if it leaks.

## When to Use This Skill

* How do I receive requests from a Tray.io workflow?
* How do I authenticate a Tray HTTP Client request with a secret header?
* Does Tray.io sign webhook payloads? (No: use a shared secret header)
* How do I route a Tray payload when the workflow, not Tray, defines the body?
* Why does my Tray endpoint return 401 or 400?

## Verification (core)

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

// Hash both sides: equal-length buffers, so timingSafeEqual never throws.
function safeEqual(a, b) {
  const ha = crypto.createHash('sha256').update(a).digest();
  const hb = crypto.createHash('sha256').update(b).digest();
  return crypto.timingSafeEqual(ha, hb);
}

function verifyTrayRequest(headers, {
  headerName = process.env.TRAY_IO_WEBHOOK_HEADER || 'Authorization',
  secret = process.env.TRAY_IO_WEBHOOK_SECRET,
} = {}) {
  if (!secret) return false;                        // never accept when unconfigured
  const received = headers[headerName.toLowerCase()]; // Node lower-cases header names
  if (typeof received !== 'string' || !received) return false;
  // Compare the WHOLE value (including any "Bearer " prefix); do not strip or parse it.
  return safeEqual(Buffer.from(received, 'latin1'), Buffer.from(secret, 'utf8'));
}

```

* Header name: user-chosen. Read it from `TRAY_IO_WEBHOOK_HEADER` (default `Authorization`) and look
  it up case-insensitively.
* Expected value: `TRAY_IO_WEBHOOK_SECRET`, the exact whole header value, including a `Bearer `
  prefix if the workflow sends one.
* Missing header or mismatch: `401`. Unconfigured secret: reject everything.
* The request method is user-chosen too: accept `POST`, `PUT`, `PATCH`, `DELETE` and `GET`, and
  accept an empty body (for example a `GET`). Hookdeck's Tray.io source pre-selects `POST` without
  managing the method, so allow whichever method your HTTP Client step uses on the source too.
* Read the body raw (`express.raw({ type: '*/*' })`) and parse JSON only after the credential check.
  Use the HTTP Client step's `raw` body type and set `Content-Type: application/json` as a header,
  because Tray has no JSON body type and defaults to `application/x-www-form-urlencoded` when none is
  chosen.

> **For complete handlers with tests**, see [examples/express/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tray-io-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tray-io-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tray-io-webhooks/examples/fastapi/).

## Payload (user-defined)

Tray defines no webhook payload and no event types: the body is whatever the HTTP Client step
sends. The examples assume you add an `event` field to the body you build, such as:

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

```

The `event` field and these values are this skill's own convention, not Tray names; replace them
with your own.

| Event (illustrative) | Meaning (your choice) |
| --- | --- |
| `record.created` | A record was created in the source system |
| `record.updated` | A record was changed |
| `record.deleted` | A record was removed |

## Environment Variables

```bash
TRAY_IO_WEBHOOK_HEADER=Authorization        # header name you add in the HTTP Client step
TRAY_IO_WEBHOOK_SECRET=your-secret-value    # exact, whole header value (e.g. "Bearer abc123")

```

## Local Development

Tray can't reach `localhost`. Tunnel with the Hookdeck CLI (no account required):

```bash
npx hookdeck-cli listen 3000 tray-io --path /webhooks/tray-io

```

Use the printed URL as the HTTP Client step's URL.

## Resources

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tray-io-webhooks/references/overview.md) - How Tray HTTP Client requests work, body shape
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tray-io-webhooks/references/setup.md) - Add the secret header in the HTTP Client step
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/tray-io-webhooks/references/verification.md) - Header check details, limits, debugging 401s
* [examples/](https://github.com/hookdeck/webhook-skills/tree/main/skills/tray-io-webhooks/examples/) - Runnable Express, Next.js and FastAPI handlers with tests