Gareth Wilson Gareth Wilson

Guide to TextUs Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationThe web app (two events only) or the Integrations API (all eight)
Event nameThe action field in the body
Signature headerX-TextUs-Signature: <hex>
SchemeHMAC-SHA256, lowercase hex, no prefix
Signed contentThe raw request body only
Secretconfig.webhookSecret, used whole as the key
Body formatJSON-LD (@type, @context)
SuccessAny 2xx
Transient errors504: retried with exponential backoff for up to 12 hours
Other errorsThe integration is marked failed, held events are retried hourly and replayed after a 2xx
ThrottlingNone
Source IPsNot published, and TextUs advises against allowlisting

Common events

TextUs documents eight events:

EventSent when
message.receivedAn incoming message was received
message.deliveredAn outgoing message was delivered
message.failedAn outgoing message failed to deliver
message.unknownAn outgoing message couldn't be delivered because of an unknown error
phone_call.completedA phone call was completed
contact.opted_outA contact opted out of text messages
contact.opted_inA contact opted back in
contact.createdA 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:

{
  "@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:

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.

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:

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 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 and raises 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 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 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 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.

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.

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 sits in front of your receiver, checks every TextUs signature, and holds each event until your service has processed it.

Get started with Hookdeck for free and handle TextUs webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.