# Guide to TextUs Webhooks: Features and Best Practices

TextUs webhooks tell your systems about the text conversations your recruiters and staff have with candidates and contacts: messages received, delivered or failed, calls completed, contacts created, and contacts opting out of or back into texts. If your team texts through TextUs, webhooks are how that activity reaches your applicant tracking system, CRM or compliance records.

This guide covers how TextUs webhook integrations are registered and signed, the events and payload shape, TextUs's unusual error handling, and the practices that keep a TextUs receiver reliable.

## What are TextUs webhooks?

In TextUs, a webhook is an integration with the provider `webhook`. It has an endpoint URL, a secret and a set of event subscriptions. When a subscribed event happens, TextUs sends an HTTPS `POST` with a JSON-LD body whose `action` field names the event.

Each delivery has an `X-TextUs-Signature` header containing the bare hex HMAC-SHA256 of the raw body, keyed with the webhook's secret. TextUs's example secret is `textus-HOTh4kXxHIbYst0xutpkdw`, and the whole string, `textus-` prefix included, is the key.

## TextUs webhook features

| Feature | Details |
| --- | --- |
| Configuration | The web app (two events only) or the Integrations API (all eight) |
| Event name | The `action` field in the body |
| Signature header | `X-TextUs-Signature: <hex>` |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The raw request body only |
| Secret | `config.webhookSecret`, used whole as the key |
| Body format | JSON-LD (`@type`, `@context`) |
| Success | Any `2xx` |
| Transient errors | `504`: retried with exponential backoff for up to 12 hours |
| Other errors | The integration is marked failed, held events are retried hourly and replayed after a `2xx` |
| Throttling | None |
| Source IPs | Not published, and TextUs advises against allowlisting |

## Common events

TextUs documents eight events:

| Event | Sent when |
| --- | --- |
| `message.received` | An incoming message was received |
| `message.delivered` | An outgoing message was delivered |
| `message.failed` | An outgoing message failed to deliver |
| `message.unknown` | An outgoing message couldn't be delivered because of an unknown error |
| `phone_call.completed` | A phone call was completed |
| `contact.opted_out` | A contact opted out of text messages |
| `contact.opted_in` | A contact opted back in |
| `contact.created` | A contact was created |

Each body is a JSON-LD delivery object. The top level has `@type`, `@context`, an `id` that identifies the delivery (`/integrations/<id>/deliveries/<id>`), `action`, `timestamp` and `webHook`, plus the object the event is about. Here is TextUs's `contact.opted_out` example:

```json
{
  "@type": "OptOutWebhookDelivery",
  "@context": "/contexts/OptOutWebhookDelivery.jsonld",
  "id": "/integrations/KYxmBL/deliveries/f8db6d71-04bd-47cb-9983-d6fd2015ce3b",
  "action": "contact.opted_out",
  "timestamp": "2021-07-27T18:48:16.878086+00:00",
  "webHook": "/integrations/KYxmBL",
  "optOut": {
    "@type": "OptOut",
    "@context": "/contexts/OptOut.jsonld",
    "id": "/textus/opt_outs/50711",
    "type": "opt-out",
    "phoneNumber": "+15551234567",
    "formattedPhoneNumber": "(555) 123-4567",
    "account": "/accounts/textus"
  }
}

```

Message events carry `conversation` and `message` objects, and `contact.created` carries a `contact`. IDs are paths such as `/contacts/ZNZD6Y` rather than bare identifiers. TextUs publishes examples for four of the eight events, so log the others before relying on their fields.

## Setting up TextUs webhooks

The quickest route is the web app, but it subscribes only to `message.delivered` and `message.received`:

1. Log in to `next.textus.com` as an org admin, open **Accounts**, and **View** your org account at the top of the hierarchy.
2. Go to **Settings > Account Settings > Integrations**.
3. Under **Webhooks**, click **Add Webhook**, enter your HTTPS URL and save.

For the other events, register the integration through the Integrations API, using your primary non-messaging account name from the web app's URL:

```bash
curl -X POST "https://next.textus.com/your-account-name/integrations" \
  -H "Accept: application/vnd.textus+jsonld" \
  -H "Authorization: Bearer $TEXTUS_API_TOKEN" \
  -d '{
    "@type": "Integration",
    "@context": "/contexts/Integration.jsonld",
    "active": true,
    "provider": "webhook",
    "config": {
      "baseUrl": "https://example.com/webhooks/textus",
      "webhookSecret": "textus-your-secret"
    },
    "settings": {
      "actions": {
        "message.received": true,
        "message.delivered": true,
        "message.failed": true,
        "message.unknown": true,
        "phone_call.completed": false,
        "contact.opted_out": true,
        "contact.opted_in": true,
        "contact.created": false
      }
    }
  }'

```

TextUs describes `webhookSecret` as "a user-defined token that provides an additional layer of security", and its own API example leaves it out. It doesn't say whether an integration without one is signed, so set a secret, store it as `TEXTUS_WEBHOOK_SECRET`, and check that deliveries carry `X-TextUs-Signature` before you rely on them.

If you receive TextUs webhooks through Hookdeck, use the Hookdeck source URL as `baseUrl` and paste the whole secret into the source, including any `textus-` prefix.

## Securing TextUs webhooks

Compute an HMAC-SHA256 of the raw body with the whole secret as a UTF-8 key, hex-encode it, and compare it with `X-TextUs-Signature` in constant time. TextUs calls verification optional, but the signature is the only check that the request came from TextUs.

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

function verifyTextUsSignature(rawBody, header, secret) {
  if (typeof header !== 'string' || !secret) return false;
  // Check the shape first: Buffer.from(x, 'hex') silently stops at invalid characters.
  if (!/^[0-9a-f]{64}$/i.test(header.trim())) return false;

  // The key is the whole secret, textus- prefix included.
  const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(header.trim(), 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/textus', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.TEXTUS_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyTextUsSignature(req.body, req.get('x-textus-signature'), secret)) {
    return res.sendStatus(401);
  }

  const delivery = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on delivery.id, route on delivery.action, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_textus_signature(raw_body: bytes, header, secret: str) -> bool:
    if not header or not secret:
        return False
    expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    # Compare bytes so a non-ASCII header fails instead of raising TypeError.
    return hmac.compare_digest(expected.encode("utf-8"), header.strip().lower().encode("utf-8"))

```

TextUs's Ruby sample compares the digests with `==`. Use a constant-time comparison instead, as above.

> **Make TextUs webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-TextUs-Signature` header, absorbs bursts of message events, and delivers them to your services at a rate they can handle, with retries and replay you control.

## TextUs webhook limitations and pain points

### One error can pause the whole integration

**The Problem:** A single bad response stops all deliveries for up to an hour.

**Why It Happens:** TextUs treats a `504` as transient and retries it with backoff for up to 12 hours. Any other `4xx` or `5xx` is a fatal error: the integration is marked failed, TextUs pauses sending and stores events, and it retries hourly until it gets a `2xx`, then replays what it held. A `500` from one malformed event therefore delays every event behind it.

**Workarounds:**

* Return `2xx` once the signature checks out, and handle processing errors yourself afterwards.
* Return `504`, not `500` or `503`, when you need TextUs to retry soon.
* Catch exceptions in the handler so a bug in one event's processing doesn't turn into a `500`.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and returns a success response, so errors in your service never mark the TextUs integration as failed. Hookdeck then delivers to your service with [automatic retries](/docs/retries) and raises [issues](/docs/issues) when deliveries keep failing.

### No throttling

**The Problem:** A busy texting day overwhelms the endpoint.

**Why It Happens:** TextUs sends events as fast as they happen and says it does not throttle them. Message events in particular can arrive in bursts, and slow responses lead to retries and duplicates.

**Workarounds:**

* Put a queue between the endpoint and your processing, as TextUs recommends.
* Scale the endpoint for peak texting hours.

**How Hookdeck Can Help:** Hookdeck queues events and can [limit the delivery rate](/docs/destinations#set-a-max-delivery-rate) to your service, so bursts are smoothed out instead of failing.

### Only two events from the web app

**The Problem:** Opt-outs, failures and contact events never arrive.

**Why It Happens:** A webhook added in the web app is subscribed only to `message.delivered` and `message.received`. The other six events need the Integrations API.

**Workarounds:**

* Register or update the integration through the Integrations API, with the `actions` you need set to `true`.
* Subscribe to `contact.opted_out` if you keep your own opt-out records.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) on `action` can send compliance events such as opt-outs to a separate destination from routine message traffic.

### No signed timestamp

**The Problem:** A captured request still verifies if it is sent again.

**Why It Happens:** Only the body is signed, and TextUs sends no timestamp or delivery header.

**Workarounds:**

* Deduplicate on the delivery `id` in the body, and for message events on the message's own `id`.
* Keep the endpoint on HTTPS.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries with the same body `id`.

## Best practices

### Verify against the raw body

The HMAC covers the exact bytes TextUs sent. Use `express.raw()` in Express, `await request.text()` in a Next.js route handler and `await request.body()` in FastAPI, and parse only after the check passes.

### Deduplicate deliveries

TextUs says slow responses can cause it to resend an event that you already processed. Store the delivery `id`, or the message `id` for message events, and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Treat IDs as paths

Values such as `/contacts/ZNZD6Y` and `/integrations/h13Jc5` are API paths. Store them as they are, or take the last segment consistently.

### Don't allowlist IPs

TextUs publishes no IP ranges and warns that its addresses change. Rely on the signature instead.

### Acknowledge fast and process asynchronously

Verify, persist the delivery, return `200`, and do the work afterwards. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

TextUs signs each delivery with `X-TextUs-Signature`, a bare hex HMAC-SHA256 of the raw body keyed with the whole webhook secret. Set a secret when you register the integration, use the Integrations API for anything beyond the two default message events, route on `action`, and return `2xx` quickly, because any error other than `504` pauses the whole integration.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every TextUs signature, and holds each event until your service has processed it.

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