ready. Try Ready →Sign up

#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

EventSent whendata
inbound_replyA contact texts one of your numbers.message_id, contact_id, from, to, body, segments
Detailsmessage_id is Ready's numeric id for the incoming message. segments is missing for numbers connected through GoHighLevel.
message_deliveredThe carrier confirms delivery. Once per attempt.
DetailsWhen Ready resends a failed text, the resend fires its own event under the same public message_id (see Failures Ready resends for you).
message_id, to, contact_id, status, error_reason, error_detail, error
Detailsmessage_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_failedThe 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, a message_delivered or another message_failed follows under the same public id.
contact_opted_outA 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 onlyA 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.

Response
{
  "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
  }
}

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 timestamp is 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-Signature header? 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.

Verify a webhook
// 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);

#Get webhook settings

GET/v1/webhooks

Your webhook URL, events and on/off state.

  • Cost Free

Returns

data.urlstring | null
Your endpoint.
data.eventsstring[]
Subscribed events.
data.enabledboolean
Always false when there's no URL.

Errors

HTTPCodeWhen
500internal_errorRead failed.

#Set webhook settings

PUT/v1/webhooks

Sets your webhook URL and events. Saving replaces the previous settings.

  • Cost Free

Body parameters

urlstringRequired
Your endpoint, sent on every call. To pause and keep it, send "enabled": false.
DetailsUse https://: 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.
DetailsIt must be an array: anything else subscribes you to all four. Unknown names are ignored, not rejected, so check events in the response: an empty list means you'll receive nothing. contact_created can only be set in the dashboard, and saving over the API removes it.
enabledbooleanOptionalDefault true
Send the JSON boolean false to pause.
regenerate_secretbooleanOptional
Send the JSON boolean true to create a new signing secret. The old one stops working immediately.
DetailsThe response includes secret the first time you save, when Ready creates your secret, and whenever you send true. That's the only time the API shows it: GET /v1/webhooks doesn'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

dataobject
url, events, enabled, and secret only when this save created or rotated it.

Errors

HTTPCodeWhen
400invalid_urlNot a URL, not http or https, or a private or internal host.
400invalid_fieldurl is a list or object, or enabled or regenerate_secret isn't a JSON boolean.
400url_requiredurl is missing.
403permission_deniedThe key's user lacks manage_integrations.
403test_key_not_allowedCalled with a test key.