# Guide to Travtus Webhooks: Features and Best Practices

[Travtus](https://travtus.com/) makes Adam, an AI assistant for residential property management teams. Adam talks to residents by SMS, email and web chat, turns their requests into tasks, and keeps lease records alongside. Travtus webhooks push those changes to your property management system, and they also carry Adam's replies when you send resident messages through the Travtus Messaging API.

| Aspect | Value |
| --- | --- |
| Method | `POST`, JSON body |
| Signature headers | `svix-id`, `svix-timestamp`, `svix-signature` (Standard Webhooks) |
| Success | Any `2xx` within 15 seconds |
| Retries | Immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, then 10 h again |
| Disabled endpoints | An endpoint failing for 5 days is disabled and an `EndpointDisabledEvent` is sent |
| Delivery | At-least-once; `svix-id` is stable across retries |

Below: how Travtus webhooks are requested and managed, the lease, task and messaging events, Svix verification, and the parts of Travtus's documentation that are thin.

## What are Travtus webhooks?

Travtus delivers webhooks through Svix, which it calls its webhook partner. Its [authentication page](https://docs2.travtus.com/travtus-api-and-webhook/webhook/authentication) shows the Svix library verifying the `svix-id`, `svix-timestamp` and `svix-signature` headers with a `whsec_` secret.

Setup starts with a person rather than a settings page: you email Travtus support with your URL and they send back the secret. After that, a Svix application portal, opened through a short-lived URL from the API, lets you add endpoints, choose event types, send test events and recover failed messages.

## Travtus webhook features

| Feature | Details |
| --- | --- |
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | `svix-signature` |
| Body | JSON |
| Event routing | Body field `event_type` |
| Delivery platform | Svix |
| Setup | Email support@travtus.com, or the webhook portal |
| Secret | `whsec_...`, one per endpoint |
| Response window | 15 seconds |
| Retry span | A little over a day, ending with two 10-hour gaps |
| Replay | Single resend, or bulk recovery from a date |
| Test events | Portal Testing tab |

## Events and payloads

Every delivery has three top-level fields:

* `event_type` is the event name.
* `message_id` is the Svix-assigned message ID. Don't confuse it with `body.message_id` on
  Messaging API replies, which is the original message's identifier.
* `body` is the lease, the task (with a `creator` object) or the AI reply.
* Deduplicate on the `svix-id` header (stable across retries).

### Common Event Types

| Event | Triggered When | `body` fields |
| --- | --- | --- |
| `lease.update` | A lease is created or updated | `identifier`, `start_date`, `end_date`, `move_in_date`, `rent`, `deposit`, `notice_given`, `notice_date`, `move_out_date_requested` |
| `task.create` | A task is created | `identifier`, `external_ref`, `title`, `summary`, `category`, `topic`, `importance`, `status`, `queue_name`, `due_datetime`, `creator`, and more |
| `task.update` | A task is updated | same as `task.create` |
| `task.delete` | A task is deleted | same as `task.create` (`status` is `deleted`) |
| `sms.respond` | AI response to an SMS message | `message_id`, `conversation_id`, `sender_name`, `message_text`, `channel`, `company_id`, `human_handover_ind`, `portfolio_external_ref` |
| `email.respond` | AI response to an email message | same as `sms.respond` |
| `web_chat.respond` | AI response to a web chat message | same as `sms.respond` |

Travtus documents these 7 events, and the list may not be complete.

Task `status` is one of `created`, `in_progress`, `completed`, `deleted`. Task `importance` is
`low`, `normal`, `high` or `very_high`. For Messaging API replies `channel` is `sms`, `email` or
`website`, and `human_handover_ind` is `true` when Travtus suggests handing over to a human.

Tasks also carry `priority_score` (0 to 1000), `escalation_flag`, `onsite_indicator` (`onsite` or `offsite`) and a `queue_name` from a fixed list of departments such as `Facilities`, `Leasing` and `Renewals`, or `null`. Either `identifier` or `external_ref` can be used to look a task up again. `relevant_conversation` can hold the transcript that led to the task, and `summary` has no length limit, so plan storage accordingly.

### Published payload example

This is the `task.update` sample from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/travtus/latest/task.update.json). Use its fields to explore the payload; it is a documentation example.

```json
{
  "body": {
    "action_type": "Assistance",
    "assignee_id": null,
    "attachments": {
      "file_A": "https://u.dev.hiadam.co/pi5fwe3"
    },
    "completed_datetime": null,
    "creator": {
      "email": "support@travtus.com",
      "unit_external_ref": "P~4770692~467~2016",
      "lease_external_ref": "A1234",
      "first_name": "Bob",
      "last_name": "James",
      "phone_number": "+447549111111",
      "resident_address": "10 Executive Park West NE",
      "unit_identifier": "4efdccb7-10ca-47b0-811c-de2fae9f6682",
      "unit_number": "2016"
    },
    "datetime_created": "2023-12-20T17:32:49.143000+00:00",
    "datetime_updated": "2024-01-15T15:07:27.440000+00:00",
    "description": "a description",
    "due_datetime": "2023-12-19T12:00:00+00:00",
    "external_ref": "1234567890",
    "identifier": "26d2f5f9-b128-48cd-add8-303e067df958",
    "importance": "very_high",
    "notes": null,
    "portfolio_external_ref": "P1234",
    "portfolio_id": "b0234194-e61b-4a57-967f-6eaaee4d5219",
    "priority_score": 500,
    "queue_name": null,
    "relevant_conversation": null,
    "reminder_datetime": null,
    "resident_id": null,
    "status": "created",
    "category": "Customer Support",
    "summary": "a summary",
    "title": "a title",
    "onsite_indicator": "onsite",
    "topic": "maintenance",
    "user_goal": "fix_shower",
    "escalation_flag": false
  },
  "event_type": "task.update",
  "message_id": "51d155b3-496f-434e-b664-406836be35a0"
}

```

In this sample the unit and lease references sit inside `creator`, while Travtus's field table lists `unit_identifier`, `unit_number`, `lease_external_ref` and `resident_address` at the top level of `body` as well. Read them from either place.

## Setting up Travtus webhooks

1. Email [support@travtus.com](mailto:support@travtus.com) with the URL that should receive events, for example `https://example.com/webhooks/travtus`.
2. Travtus sets up the webhook and replies with a signing secret that starts `whsec_`. Store all of it, prefix included, as `TRAVTUS_WEBHOOK_SECRET`. Each endpoint has its own secret.
3. To add or change endpoints yourself, request a portal link with `GET https://api.travtus.com/webhook/app-portal-access/` and your API bearer token. The response is `{ "url": "..." }`, a short-lived link to the portal.
4. In the portal's **Endpoints** section, add the URL and pick event types. With none selected, the endpoint receives every event, which is handy while testing.
5. Use the **Testing** tab to send example events, then open each message to see the payload and attempts. **Svix Play** gives you a temporary URL if your service isn't ready.

To receive deliveries on your own machine, use the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 travtus --path /webhooks/travtus

```

**With Hookdeck.** Give Travtus a **Travtus** source's URL as your endpoint. Travtus sets up webhooks through support@travtus.com and sends you the `whsec_` secret; use it as the source's verification secret. The source checks the `svix-*` headers, accepts only `POST` and answers `200` well inside Travtus's 15 seconds. Requests that fail verification are recorded as failed and go no further.

## Securing Travtus webhooks

Travtus's examples use the Svix library, which computes HMAC-SHA256 over `{svix-id}.{svix-timestamp}.{raw body}` with the base64-decoded secret after `whsec_` and compares the result with each `v1,` value in `svix-signature`. The same check without the library:

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

function decodeBase64(value) {
  if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
  const bytes = Buffer.from(value, 'base64');
  return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
    ? bytes : null;
}

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const prefix = headers['svix-id'] ? 'svix' : 'webhook';
  const id = headers[`${prefix}-id`];
  const timestamp = headers[`${prefix}-timestamp`];
  const signature = headers[`${prefix}-signature`];
  if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
      !/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
  const key = decodeBase64(secret.replace(/^whsec_/, ''));
  if (!key) return false;
  const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
  const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    const received = decodeBase64(candidate);
    if (!received) return false;
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

Travtus asks you to verify the payload exactly as sent and to disable CSRF protection on the route. An Express handler that files tasks and passes replies on:

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

// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
  try {
    const value = JSON.parse(rawBody.toString('utf8'));
    return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
  } catch {
    return null;
  }
}

const app = express();

app.post('/webhooks/travtus', express.raw({ type: '*/*' }), async (req, res) => {
  const secret = process.env.TRAVTUS_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500);
  if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);

  const payload = parseJsonObject(req.body);
  if (!payload) return res.sendStatus(400); // signed, but not a JSON object
  const { event_type, message_id, body } = payload;
  if (typeof event_type !== 'string' || !message_id || !body || typeof body !== 'object') return res.sendStatus(400);
  try {
    if (await deliveries.seen(message_id)) return res.sendStatus(200);

    switch (event_type) {
      case 'task.create':
      case 'task.update':
      case 'task.delete':
        await workOrders.sync(body.identifier, body.status, body);
        break;
      case 'sms.respond':
      case 'email.respond':
      case 'web_chat.respond':
        if (body.human_handover_ind) await staff.assign(body.conversation_id);
        await conversations.reply(body.conversation_id, body.channel, body.message_text);
        break;
      case 'lease.update':
        await leases.upsert(body.identifier, body);
        break;
      default:
        break;
    }
    await deliveries.record(message_id);
  } catch {
    return res.sendStatus(503); // not processed, so don't acknowledge
  }
  return res.sendStatus(200);
});

```

`deliveries`, `workOrders`, `staff`, `conversations` and `leases` stand for your own code. Travtus describes the top-level `message_id` as the Svix-assigned message ID, and its task page says it's there for deduplication. For anything that takes more than a few seconds, queue the event and answer first.

Travtus says the signed timestamp helps prevent replay attacks, and the Svix library rejects one more than five minutes old. Behind Hookdeck that check would fail on retries and replays, because Hookdeck keeps the original `svix-timestamp` and applies no tolerance of its own. The function above checks only the signature.

> **Make Travtus webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) answers Travtus within its 15-second window, retries your service when it's down, and routes task events and AI replies to different systems.

## Travtus webhook limitations and pain points

### Setup goes through support

**The Problem:** You can't create the first webhook or get its secret on your own.

**Why It Happens:** Travtus's setup instructions start with an email to support@travtus.com, and the secret comes back from support.

**Workarounds:**

* Ask for staging and production endpoints in the same request.
* Each endpoint has its own secret, so a re-created endpoint means asking for the new `whsec_` value.
* Once you have API access, use the portal link to add further endpoints yourself.

**How Hookdeck Can Help:** Point Travtus at one Hookdeck source and add or change your own destinations in Hookdeck, without going back to support.

### A 15-second limit and disabling after 5 days

**The Problem:** Slow handlers cause retries, and a long outage disables the endpoint.

**Why It Happens:** Travtus counts a response after 15 seconds as a failure, and disables an endpoint that fails for 5 days.

**Workarounds:**

* Answer first and process from a queue.
* After an outage, re-enable the endpoint in the portal with **Enable Endpoint**, then use **Recover Failed Messages**.

**How Hookdeck Can Help:** Hookdeck answers Travtus straight away, so the endpoint stays healthy while your service is down, then [retries](/docs/retries) delivery to you.

### Lease events are thinly documented

**The Problem:** You can't be sure which lease events exist or what every field looks like.

**Why It Happens:** The lease page says events fire when a lease is created or updated but shows only a `lease.update` example, which is missing a comma and isn't valid JSON. No `lease.create` is documented, and `sms.respond` and `email.respond` have no examples of their own.

**Workarounds:**

* Subscribe to all events while testing, and log any `event_type` you don't recognise.
* Parse fields defensively until you've seen real deliveries.

**How Hookdeck Can Help:** Hookdeck stores every request in full, so you can see the real lease payloads and event names.

### Two message IDs in reply events

**The Problem:** Deduplicating on the wrong ID drops real replies or keeps duplicates.

**Why It Happens:** Messaging replies have a top-level `message_id` (the Svix message) and a `body.message_id` (the resident's original message).

**Workarounds:**

* Deduplicate on the top-level `message_id` or the `svix-id` header, and use `body.message_id` only to link the reply to its conversation.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can key on the `svix-id` header, so retried deliveries are dropped before they reach you.

## Best practices

### Plan for human handover

When `human_handover_ind` is `true`, route the conversation to staff rather than only posting Adam's reply.

### Sort tasks by priority

Use `importance`, `priority_score` and `escalation_flag` to order work, and `queue_name` to send it to the right team.

### Keep track of endpoint health

The portal shows attempts per message. Check it after any deployment that touches the webhook route.

### Make handlers idempotent

Travtus keeps retrying for more than a day, so store the message ID with each change. Our [webhook idempotency guide](/webhooks/guides/implement-webhook-idempotency) explains how.

## Conclusion

Travtus webhooks connect Adam's resident conversations to the rest of your property stack: tasks for your maintenance and leasing teams, lease changes, and AI replies to deliver. The Svix delivery is solid and well described. Setup through support, the 15-second limit and thin lease documentation are the parts to plan for.

[Hookdeck Event Gateway](https://hookdeck.com) keeps your Travtus endpoint responsive and routes each event to the right system. [Sign up](https://dashboard.hookdeck.com/signup) and send Travtus support a Travtus source URL.