SMS Webhooks and Consent
This page is the contract for integrations that send texts with Batch SMS and need to track replies, opt-outs and delivery. It covers webhook signing, the SMS events, how to correlate an event with your campaign, opt-out (STOP) handling, cancellation, and delivery guarantees.
Two hosts are involved:
| Host | Used for |
|---|---|
https://api.askbenny.ca | Creating campaigns: POST /v1/organizations/{organizationId}/batch-sms |
https://api-v2.askbenny.ca | Reading and cancelling campaigns, and managing opt-outs (endpoints below) |
Every endpoint uses the same organization-scoped x-api-key header. A key that belongs to another organization is rejected.
Webhook setup
Configure one endpoint per organization in the dashboard under Settings → Developers → Webhooks: URL (HTTPS), signing secret, the master switch, and one toggle per event. New events are off until you turn them on. Send test event delivers a fake payload of the event you choose, signed with your saved secret; test payload ids start with whevt_test_.
Signature verification
Each delivery is an HTTP POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | sha256= followed by the lowercase hex HMAC-SHA256 of the raw request body, keyed with your signing secret (UTF-8) |
X-Webhook-Id | The event id. Always equal to the body's id |
X-Webhook-Event | The event name. Always equal to the body's event |
X-Webhook-Timestamp | Unix seconds. Always equal to the body's timestamp |
The signature covers the raw body bytes only, exactly as received. Do not re-serialize the JSON before verifying. The three X-Webhook-* headers are not signed, so read the id, event and timestamp from the verified body. To reject replays, check that the body's timestamp is recent (for example, within 15 minutes; allow for the retry schedule below) and that you have not already processed its id.
To rotate the secret, save a new one in the dashboard. Deliveries made after the save, including retries of older events, are signed with the new secret. During the switch, accept either secret for a short overlap.
Test-only verification fixture. Signing secret: test_only_signing_secret_do_not_use.
Raw body (one line, exactly as sent):
{"id":"whevt_5b0f3a0e9c1d4e2f8a7b6c5d4e3f2a1b","event":"sms.recipient.opted_out","timestamp":1780000000,"organization":{"id":"org_123abc","name":"Example Corp","contact":{"phone":"+15559876543","email":"hello@examplecorp.com"},"timezone":"America/Toronto"},"data":{"customerNumber":"+15551234567","customerNumberE164":"+15551234567","status":"suppressed","source":"keyword","keyword":"stop","revision":1,"changedAt":1780000000000}}
Expected X-Webhook-Signature:
sha256=59010e8c6b5ac2450bbf78008b062a5c52acc87dc34cf7cdad684302421f3d00
import crypto from 'node:crypto';
const verify = (rawBody, header, secret) => {
const expected = `sha256=${crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')}`;
return header.length === expected.length && crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
};
Delivery, retries and ordering
- Respond with any
2xxstatus within 10 seconds. The body of your response is ignored. - Redirects are followed up to 3 times, to public addresses only and never from
httpstohttp: a302or303is fetched withGET(this is how Google Apps Script web apps answer), and a301,307or308receives the same signedPOSTagain. A redirect we cannot follow counts as a failure. - Any other result is retried: a non-
2xxstatus, a timeout, or a connection or TLS error. Each event gets three delivery attempts in total, about 6 minutes apart. After the third failure the event is held for operator recovery for 14 days; there is no self-service replay. Once your endpoint is fixed, contact support to have held events sent again (with their originalid). - Settings > Developers > Webhooks shows when events stop being delivered: how many events in a row failed, when the last failure happened, and why (the HTTP status your endpoint returned, a timeout, a refused or reset connection, a TLS certificate problem, or a domain that does not resolve). A
401or403usually means your endpoint rejected the signature; check that it verifies with the signing secret saved there. The status clears when you save a new URL or secret, or switch delivery back on. - Configuration is re-checked at delivery time: turning off the webhook or an event's toggle drops that event's pending deliveries.
- Delivery is at least once. The same event can arrive more than once and always has the same
id, including on retries. Useidas your idempotency key. - Ordering is not guaranteed, not even for one recipient. Use
changedAt(consent events) ormessage.timestampto order events. For consent events, the highestrevisionfor a recipient is that recipient's current state.
SMS events
| Event | Dashboard toggle | Fires when |
|---|---|---|
sms.message.created | SMS message sent | A message is sent to a customer, including each campaign's opening message, agent replies and manual replies |
sms.message.received | SMS message received | A customer texts your business number, including STOP and START |
sms.message.status_updated | SMS delivery status | The carrier reports a campaign message as sent, delivered, undelivered or failed |
sms.recipient.opted_out | SMS opt-out and opt-in | A recipient becomes suppressed |
sms.recipient.opted_in | SMS opt-out and opt-in | A suppression is lifted |
Coverage. Opt-out, opt-in and delivery-status events cover every number. A campaign's opening message fires sms.message.created once its conversation is saved. In the rare case that saving fails after the carrier accepted the message, the send is still recorded as sent (with its messageSid) in campaign status and still receives delivery-status events, so treat campaign status as the source of truth and reconcile against it. For a business number set up through the Ask Benny phone system, the rest of the conversation (customer replies and agent follow-ups) does not yet emit sms.message.created or sms.message.received; opt-outs from those numbers are still recorded and still emit sms.recipient.opted_out.
Every event body has the same envelope: id, event, timestamp (unix seconds), organization (id, name, contact details, timezone) and data. The organization.id always matches the organization that owns the endpoint. Inside data, every time value is in unix milliseconds.
sms.message.created and sms.message.received
{
"id": "whevt_0a1b2c3d4e5f60718293a4b5c6d7e8f9",
"event": "sms.message.created",
"timestamp": 1780000000,
"organization": { "id": "org_123abc", "name": "Example Corp", "contact": { "phone": "+15559876543" }, "timezone": "America/Toronto" },
"data": {
"threadId": "thread-15551234567",
"direction": "outbound",
"customerNumber": "15551234567",
"customerNumberE164": "+15551234567",
"agentNumber": "15559876543",
"agentNumberE164": "+15559876543",
"contactName": "John Smith",
"messageCount": 1,
"message": {
"id": "SM00000000000000000000000000000001",
"role": "agent",
"body": "Hi John, you have a new message in your portal. Reply STOP to opt out.",
"messageSid": "SM00000000000000000000000000000001",
"timestamp": 1780000000000
},
"outboundCampaign": {
"campaignId": "batch-sms-3f9a2c",
"campaignName": "Portal notifications",
"idempotencyKey": "portal-notification-0001",
"isOpeningMessage": true
}
}
}
sms.message.received has the same shape with direction: "inbound" and role: "customer". A business has one thread per customer phone number, so threadId alone does not identify a campaign.
Phone numbers. SMS events carry customerNumberE164 in E.164 (+15551234567), and message events also carry agentNumberE164. Use these to join events. Each is present whenever we know the number with its country code; we never guess one, so a field is omitted rather than filled with a number that may be wrong. customerNumber and agentNumber keep the format they have always had for compatibility: on message events that is often digits only (15551234567), while delivery-status and consent events already use E.164.
Correlating an event with your campaign
data.outboundCampaign is present whenever a message belongs to a campaign:
campaignIdis theidreturned when you created the campaign. When you pass anidempotencyKey, the campaign id is derived from it, andidempotencyKeyis echoed back verbatim.isOpeningMessage: truemarks the campaign's own message to that recipient, identified by itsmessageSid. That attribution never changes, even after a later campaign texts the same number.- Replies and agent follow-ups carry the campaign that governed the thread when they were written, with
isOpeningMessage: false. Messages older than the thread's current campaign carry nooutboundCampaign.
To match a portal notification to a send, key on campaignId (or your idempotencyKey) plus customerNumberE164. messageSid is always present on sms.message.status_updated and identifies the message: it is the per-recipient messageSid returned by Get campaign status, and it matches message.messageSid on the corresponding sms.message.created when that event was emitted.
sms.message.status_updated
{
"id": "whevt_7c2e…",
"event": "sms.message.status_updated",
"timestamp": 1780000005,
"organization": { "id": "org_123abc", "name": "Example Corp" },
"data": {
"messageSid": "SM00000000000000000000000000000001",
"status": "delivered",
"customerNumber": "+15551234567",
"customerNumberE164": "+15551234567",
"threadId": "thread-15551234567",
"outboundCampaign": {
"campaignId": "batch-sms-3f9a2c",
"campaignName": "Portal notifications",
"idempotencyKey": "portal-notification-0001",
"isOpeningMessage": true
}
}
}
status is the carrier's report for the campaign's opening message: sent (handed to the carrier), then one of delivered (handset delivery confirmed), undelivered or failed. errorCode is included for failures; it is the carrier's numeric error code (for example 30003, or 21610 when the recipient has opted out). Each status is reported at most once per message. A status that arrives after a later one, for example sent after delivered, is ignored. Status events cover campaign opening messages only, not agent replies.
Keep three things apart:
- Accepted: the campaign accepted the recipient, which shows as
pendingin campaign status. - Sent: the message was handed to the carrier, which shows as delivery
status: "sent"andsms.message.created. - Delivered: the carrier confirmed handset delivery, which shows as
providerStatus: "delivered"andsms.message.status_updated.
Some carriers never confirm delivery, so a message can stay at sent.
sms.recipient.opted_out and sms.recipient.opted_in
{
"id": "whevt_5b0f3a0e9c1d4e2f8a7b6c5d4e3f2a1b",
"event": "sms.recipient.opted_out",
"timestamp": 1780000000,
"organization": { "id": "org_123abc", "name": "Example Corp" },
"data": {
"customerNumber": "+15551234567",
"customerNumberE164": "+15551234567",
"status": "suppressed",
"source": "keyword",
"keyword": "stop",
"revision": 1,
"changedAt": 1780000000000
}
}
source is one of:
keyword: the recipient texted an opt-out keyword.provider: the carrier reported the recipient as opted out.api: your integration recorded the suppression.
sms.recipient.opted_in reports status: "resumed", with source set to keyword (the recipient texted START) or api. Each change increments revision, and repeating the same change produces no event. Every committed change produces its event, even if the first enqueue attempt fails. A START older than the current opt-out (for example, a delayed message) never lifts it. A recipient who texts STOP twice gets one opted_out event.
Opt-outs (STOP)
A text is an opt-out when the whole message is one of these words (case-insensitive, surrounding spaces and trailing punctuation ignored): STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT, REVOKE, OPTOUT, ARRET, ARRÊT. START, UNSTOP and YES opt back in, but only for a recipient who is currently suppressed by a keyword or the carrier. A longer message such as "cancel my appointment" is an ordinary message.
When a recipient opts out:
- Suppression applies to the organization (every number it sends from) and is recorded before anything else. If the opt-out cannot be recorded, the message is not acknowledged as handled, rather than silently dropped. If consent cannot be checked for a later message, Benny withholds its reply. The carrier also blocks further texts from that sending number.
- The
STOPmessage appears in the conversation and is delivered assms.message.received. Benny does not reply to it; the carrier sends its own confirmation. Benny also does not reply to any later message from that number until the recipient opts back in. - Manual sends from the dashboard are refused, and the agent-message API reports the recipient as failed with
Recipient has opted out of text messages. - Recipients in campaigns that have not reached them yet are skipped. This applies to scheduled, in-progress and paused campaigns, and to campaigns you create later. A skipped recipient's status is
suppressed; they are never texted and are not counted as sent or failed. - A message already handed to the carrier before the opt-out cannot be recalled.
Opting back in. A keyword or carrier opt-out can only be lifted by the recipient texting START to the business number. No API call, new campaign, profile update or dashboard action can bypass it, and the resume endpoint returns 409 (SUPPRESSION_RESUME_REQUIRES_RECIPIENT). A suppression your integration created can be lifted with the resume endpoint after you collect fresh, explicit consent; the recipient's START does not lift it.
Opt-out endpoints
All three endpoints are on https://api-v2.askbenny.ca, take a JSON body with the phone number (E.164, never in the URL), and return the recipient's state:
{
"message": "Recipient suppressed",
"data": {
"phoneNumber": "+15551234567",
"suppressed": true,
"status": "suppressed",
"source": "api",
"reason": "Portal SMS notifications turned off",
"suppressedAt": 1780000000000,
"revision": 1
},
"requestId": "6f1c…"
}
Suppress a recipient
POST /v1/organizations/{organizationId}/sms-suppressions
Body: { "phoneNumber": "+15551234567", "reason": "Portal SMS notifications turned off" }. reason is optional, up to 200 characters.
Records an opt-out collected by your integration, such as a portal setting. It takes effect as soon as the response returns:
- Recipients who have not been texted yet, in any campaign, are skipped. Each campaign checks suppression immediately before each send.
- A message already handed to the carrier is not recalled.
- Benny does not reply to the recipient.
The call returns 201 when the recipient becomes suppressed, and 200 when they already were; an existing suppression keeps its original source. A 400 with code INVALID_PHONE_NUMBER or INVALID_REQUEST means the body was rejected.
Look up a recipient
POST /v1/organizations/{organizationId}/sms-suppressions/lookup
Body: { "phoneNumber": "+15551234567" }. A number with no record returns { "phoneNumber": "+15551234567", "suppressed": false }.
Resume a recipient
POST /v1/organizations/{organizationId}/sms-suppressions/resume
Body: { "phoneNumber": "+15551234567" }. This lifts a suppression with source: "api" and returns 200. A recipient who was not suppressed returns 200 with "suppressed": false. A recipient suppressed by keyword or provider returns 409 with code SUPPRESSION_RESUME_REQUIRES_RECIPIENT.
Errors
Errors on api-v2.askbenny.ca use one envelope:
{ "error": { "code": "CAMPAIGN_ALREADY_FINISHED", "message": "This campaign has already finished sending and cannot be cancelled" }, "requestId": "6f1c…" }
| Status | Codes |
|---|---|
| 400 | INVALID_PATH, INVALID_REQUEST (with details.fields), INVALID_PHONE_NUMBER |
| 401 | API_KEY_REQUIRED, INVALID_API_KEY |
| 403 | ORGANIZATION_MISMATCH |
| 404 | CAMPAIGN_NOT_FOUND |
| 409 | CAMPAIGN_ALREADY_FINISHED, SUPPRESSION_RESUME_REQUIRES_RECIPIENT |