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:
{
"@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:
- Log in to
next.textus.comas an org admin, open Accounts, and View your org account at the top of the hierarchy. - Go to Settings > Account Settings > Integrations.
- 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-Signatureheader, 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
2xxonce the signature checks out, and handle processing errors yourself afterwards. - Return
504, not500or503, 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
actionsyou need set totrue. - Subscribe to
contact.opted_outif 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
idin the body, and for message events on the message's ownid. - 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.