#Webhooks
Ready POSTs a JSON event to a URL you own when something happens: a reply, a delivery, an opt-out.
Details
You need a public URL that accepts POST; no-code tools like n8n and Make.com give you one.
Events
| Event | Sent when | data |
|---|---|---|
inbound_reply | A contact texts one of your numbers. | message_id, contact_id, from, to, body, segmentsDetailsmessage_id is Ready's numeric id for the incoming message. segments is missing for numbers connected through GoHighLevel. |
message_delivered | The carrier confirms delivery. Once per attempt.DetailsWhen Ready resends a failed text, the resend fires its own event under the same publicmessage_id (see Failures Ready resends for you). | message_id, to, contact_id, status, error_reason, error_detail, errorDetailsmessage_id is the string your send returned, preserved across automatic resends. error_reason and error_detail mean the same as on GET /v1/messages/:id and take the same values. error repeats error_detail for older integrations. All three are null on delivery. |
message_failed | The carrier reports a failure. Once per attempt, and not always final.DetailsOnly for messages the carrier accepted: a send refused up front returns its error directly instead. If Ready resends the text, amessage_delivered or another message_failed follows under the same public id. | |
contact_opted_out | A contact texts STOP or another opt-out keyword.DetailsOpt-outs you add yourself, TCPA scrubbing and CRM sync don't trigger it. | contact_id, phone |
contact_created dashboard only | A contact is created one at a time (POST /contacts or in the app).DetailsImports, forms, Zapier, GoHighLevel sync and contacts created by an incoming text don't trigger it. | contact_id, first_name, last_name, phone, email, source |
Every event is a JSON POST shaped { event, timestamp, data }. There's no event id: dedupe on event plus data.message_id, or data.contact_id for contact events.
Details
timestamp is ISO 8601 UTC with milliseconds. contact_opted_out and contact_created have no message_id. contact_id is safe for them: a contact is created once, and an opt-out for a contact that is already opted out changes nothing. Ignore test events in production.
Test it: Settings → Integrations → Send test event POSTs event: "test", signed with your secret.
Details
It has a 10-second timeout. Return a 2xx, and ignore events you don't handle. Sends made with a test key don't trigger message_delivered or message_failed, so use Send test event to try your endpoint.
{
"event": "inbound_reply",
"timestamp": "2026-10-01T20:40:12.004Z",
"data": {
"message_id": 19408828,
"contact_id": 101,
"from": "+12125550199",
"to": "+14155550100",
"body": "Yes, I'm interested!",
"segments": 1
}
}
{
"event": "message_delivered",
"timestamp": "2026-10-01T20:32:09.912Z",
"data": {
"message_id": "179088672338905475132359",
"to": "+12125550199",
"contact_id": 101,
"status": "delivered",
"error_reason": null,
"error_detail": null,
"error": null
}
}
{
"event": "message_failed",
"timestamp": "2026-10-01T20:33:14.380Z",
"data": {
"message_id": "179088672338905475132360",
"to": "+15559876543",
"contact_id": 102,
"status": "failed",
"error_reason": "recipient_cannot_receive_sms",
"error_detail": "This number cannot receive text messages. It is usually a landline or a VoIP line. Call the contact instead, or remove the number from your list.",
"error": "This number cannot receive text messages. It is usually a landline or a VoIP line. Call the contact instead, or remove the number from your list."
}
}
{
"event": "contact_opted_out",
"timestamp": "2026-10-01T20:45:00.731Z",
"data": {
"contact_id": 102,
"phone": "+15559876543"
}
}
{
"event": "contact_created",
"timestamp": "2026-10-01T21:04:51.779Z",
"data": {
"contact_id": 103,
"first_name": "Ada",
"last_name": "Lovelace",
"phone": "+12125550199",
"email": "ada@example.com",
"source": "manual"
}
}
{
"event": "test",
"timestamp": "2026-10-01T21:10:00.000Z",
"data": {
"message": "Test event from Ready",
"user_id": 116
}
}
Verifying the signature
Every event carries X-Signature: sha256=<hex>: the lowercase hex HMAC-SHA256 of the exact raw request body, keyed with your whole signing secret (including the whsec_ prefix).
Details
Compare in constant time, and reject anything that doesn't match.
- Verify the raw bytes. A body that was parsed and re-serialized won't match.
- To block replays, reject events whose
timestampis more than 5 minutes old. The timestamp is inside the signed body, so check it after the signature. There's no timestamp header. The samples here do both checks. - No
X-Signatureheader? Webhooks saved before signing existed are sent unsigned. Save your webhook once, or rotate the secret, to start signing. - Treat the secret like a password. Rotating it takes effect immediately, so update your receiver at the same time.
Request headers
Content-Type: application/json, User-Agent: ReadySMS-Webhooks/1.0 (not on the test event) and X-Signature.
Delivery
Each event is sent once, with a 5-second timeout and no retries. Respond quickly with a 2xx and do slow work afterwards.
Details
Redirects are followed (up to 5) and stay POST. Events can arrive in any order. If your endpoint was down, reconcile with GET /v1/messages and GET /v1/opt-outs.
Using n8n or Make.com
Add a Webhook trigger and paste its URL into Ready's Webhook URL field (or send it with PUT /v1/webhooks).
Details
Pick your events and save. In n8n, use the Production URL, not the Test URL, which only works while the editor is listening. Signature checks are optional for no-code tools.
// Node.js + Express (save as .mjs, like the other Node examples)
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.READY_WEBHOOK_SECRET; // whsec_...
function isFromReady(rawBody, header) {
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const got = Buffer.from(String(header || ''));
const want = Buffer.from(expected);
return got.length === want.length && crypto.timingSafeEqual(got, want);
}
// express.raw keeps the exact bytes Ready signed; do not use express.json() on this route
app.post('/ready-webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!isFromReady(req.body, req.get('X-Signature'))) return res.sendStatus(401);
let event, timestamp, data;
try {
({ event, timestamp, data } = JSON.parse(req.body));
} catch {
return res.sendStatus(401);
}
// replay check: the signed timestamp must be within 5 minutes
const sent = typeof timestamp === 'string' ? Date.parse(timestamp) : NaN;
if (!Number.isFinite(sent) || Math.abs(Date.now() - sent) > 5 * 60 * 1000) return res.sendStatus(401);
res.sendStatus(200); // answer within 5 seconds
setImmediate(() => console.log('OK', event, timestamp, JSON.stringify(data)));
});
app.listen(4789);
# Python (standard library only)
import hmac, hashlib, json, os
from datetime import datetime, timezone
SECRET = os.environ["READY_WEBHOOK_SECRET"].encode() # whsec_...
def is_from_ready(raw_body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header or ""):
return False
# replay check: the signed timestamp must be within 5 minutes
try:
timestamp = json.loads(raw_body)["timestamp"]
sent = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
if sent.tzinfo is None:
return False
except (ValueError, TypeError, KeyError, AttributeError):
return False
return abs((datetime.now(timezone.utc) - sent).total_seconds()) <= 300
# Flask: is_from_ready(request.get_data(), request.headers.get("X-Signature"))
# Django: is_from_ready(request.body, request.headers.get("X-Signature"))
#Get webhook settings
/v1/webhooksYour webhook URL, events and on/off state.
Returns
data.urlstring | null- Your endpoint.
data.eventsstring[]- Subscribed events.
data.enabledboolean- Always
falsewhen there's no URL.
Errors
| HTTP | Code | When |
|---|---|---|
| 500 | internal_error | Read failed. |
#Set webhook settings
/v1/webhooksSets your webhook URL and events. Saving replaces the previous settings.
Body parameters
urlstringRequired- Your endpoint, sent on every call. To pause and keep it, send
"enabled": false.Details
Usehttps://:http://is accepted but sends your customers' messages unencrypted. The response returns the URL normalized. eventsstring[]Optional- Any of
inbound_reply,message_delivered,message_failed,contact_opted_out. Leave it out for all four.Details
It must be an array: anything else subscribes you to all four. Unknown names are ignored, not rejected, so checkeventsin the response: an empty list means you'll receive nothing.contact_createdcan only be set in the dashboard, and saving over the API removes it. enabledbooleanOptionalDefaulttrue- Send the JSON boolean
falseto pause. regenerate_secretbooleanOptional- Send the JSON boolean
trueto create a new signing secret. The old one stops working immediately.Details
The response includessecretthe first time you save, when Ready creates your secret, and whenever you sendtrue. That's the only time the API shows it:GET /v1/webhooksdoesn't return it, and Settings → Integrations shows it in full only once, when it's created, then a masked value. If you lose it, rotate it.
Returns
dataobjecturl,events,enabled, andsecretonly when this save created or rotated it.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_url | Not a URL, not http or https, or a private or internal host. |
| 400 | invalid_field | url is a list or object, or enabled or regenerate_secret isn't a JSON boolean. |
| 400 | url_required | url is missing. |
| 403 | permission_denied | The key's user lacks manage_integrations. |
| 403 | test_key_not_allowed | Called with a test key. |