Webhooks
ArelHub uses two distinct webhook patterns. This guide explains both and how to build reliable handlers for each.
Pattern 1 — Inbound provider callbacks
Endpoints hosted by ArelHub that receive event notifications from carriers and payment processors. You do not call these endpoints. ArelHub registers them with each provider.
| Route | Provider | Purpose |
|---|---|---|
POST /v1/webhooks/stripe
|
Stripe | Platform payment events |
POST /v1/webhooks/stripe/connect
|
Stripe | Connected account events |
POST /v1/webhooks/tcr
|
TCR | 10DLC status updates |
Authentication is via HMAC-SHA256 payload signature verification, not JWT or API key.
Retrieve carrier events for a dispatched message:
GET /v1/messages/{id}/carrier-events
Authorization: Bearer eyJ...
Pattern 2 — Customer webhook subscriptions
Configure ArelHub to push events to a URL you control.
GET /v1/billing/webhooks/config
POST /v1/billing/webhooks/enable
POST /v1/billing/webhooks/disable
POST /v1/billing/webhooks/regenerate-secret
After rotating the signing secret, update your handler immediately to avoid rejected deliveries.
Message webhooks
Message webhooks push message events, and the replies your recipients send back, to an HTTPS endpoint you control.
They are managed with /v1/webhooks and are separate from the billing webhooks above.
Message webhooks are signed differently from the example in the next section: the signature covers a
timestamp as well as the body. See Verifying a message
webhook.
Create an endpoint
POST /v1/webhooks
Authorization: Bearer eyJ... (or X-Api-Key)
Content-Type: application/json
{
"url": "https://example.com/arelhub/webhook",
"label": "Appointment replies",
"subscribedEvents": "MessageDelivered, MessageFailed, MessageReceived"
}
The signing secret (it starts with whsec_) is returned once, in this response. Store
it securely; if you lose it, rotate it.
urlmust be an HTTPS URL.subscribedEventsis a comma-separated list of the event names below.subaccountIdis optional. An endpoint scoped to a subaccount receives that subaccount's events, plus events that belong to no subaccount.
| Endpoint | Purpose |
|---|---|
GET /v1/webhooks | List your endpoints |
GET /v1/webhooks/{webhookId} | Get one endpoint |
PUT /v1/webhooks/{webhookId} | Change the URL, label, events or active flag |
DELETE /v1/webhooks/{webhookId} | Delete an endpoint |
POST /v1/webhooks/{webhookId}/rotate-secret | Issue a new signing secret |
POST /v1/webhooks/{webhookId}/test | Send a test event |
GET /v1/webhooks/{webhookId}/deliveries | See delivery attempts |
POST /v1/webhooks/{webhookId}/deliveries/{deliveryLogId}/replay | Send a delivery again |
Events
The eventType in each delivery is one of:
eventType |
Sent when |
|---|---|
MessageAccepted | A message was accepted for delivery |
MessageDelivered | The carrier confirmed delivery |
MessageFailed | Delivery failed |
MessageRejected | A message was rejected and will not be delivered |
MessageDeadLettered | A message ran out of retries |
MessageExpired | A message passed its expiresAt and was not sent |
MessageReceived | A recipient replied to one of your numbers |
RecipientOptedOut | A recipient's opt-out changed to opted out |
RecipientOptedIn | A recipient was re-subscribed |
RecipientOptedOut and RecipientOptedIn are separate subscriptions.
Existing endpoints do not receive them until you add them to subscribedEvents. They are sent when a
recipient's state actually changes (a STOP or START reply, or an opt-out or re-subscribe you
record), not for CSV imports. The data block has recipient, channel,
scope (subaccount, accountLevelOnly or accountWide),
sourceLabel, receivingNumber (for a text reply) and, on RecipientOptedIn,
stillSuppressed, which is true when another opt-out still blocks the recipient. An
accountLevelOnly event goes to account-level endpoints only.
MessageReceived is a separate subscription. Endpoints created before it existed do not
receive replies until you add MessageReceived to their subscribedEvents.
Delivery payload
Every delivery is a POST with a JSON body:
{
"eventType": "MessageReceived",
"timestamp": "2026-10-15T10:00:03.1200000+00:00",
"accountId": "00000000-0000-0000-0000-000000000001",
"subaccountId": null,
"messageId": "00000000-0000-0000-0000-000000000002",
"data": {
"inboundMessageId": "00000000-0000-0000-0000-0000000000a1",
"channelType": "Sms",
"from": "+15551234567",
"to": "+12025551234",
"body": "C",
"receivedAt": "2026-10-15T10:00:00+00:00",
"keyword": null,
"inReplyTo": {
"messageId": "00000000-0000-0000-0000-000000000002",
"clientReference": "appt-12345"
},
"reply": { "matched": true, "key": "C", "value": "confirm" }
}
}
For MessageReceived:
messageIdis the message the reply answers, ornullwhen the reply could not be linked to one.data.inReplyTocarries that message's id and yourclientReference(or isnullwhen unlinked). This is how you match a reply to an appointment or order without looking anything up.data.keywordisStop,StartorHelpwhen the reply was an opt-out or opt-in keyword, otherwisenull. Opt-outs are applied by ArelHub regardless; you receive the reply as information.data.replyis present only when the message the reply answers offered reply options, and isnullotherwise.{ "matched": true, "key": "C", "value": "confirm" }means the recipient chose that option. Act onvalue.{ "matched": false }means the text was not one of the options (for exampleyes please). The text is still indata.body, so you can handle it yourself.- A reply that is an opt-out, opt-in or help word is never an answer.
"afterExpiry": trueis added when the reply arrived after the message'sexpiresAt. The reply is still delivered, but it does not answer the message.
- Use
data.inboundMessageIdto deduplicate.
For the other events, messageId is the message the event is about and data holds the
clientReference you sent (if any), the previous state, a reason, and the new state (as
state or currentState). Branch on eventType, and treat unknown fields and
event types as additions to ignore.
Each request also carries these headers:
| Header | Value |
|---|---|
X-Webhook-Id | Id of the delivery |
X-Webhook-Timestamp | Unix time, in seconds, when the delivery was signed |
X-Webhook-Signature | sha256= followed by the lowercase hex HMAC-SHA256 signature |
Verifying a message webhook
The signature is the HMAC-SHA256, keyed with your signing secret, of the timestamp, a dot, and the raw
request body: {X-Webhook-Timestamp}.{body}. Compute it over the exact bytes you received
(not a re-serialised copy), compare in constant time, and reject deliveries whose timestamp is more than a few
minutes old so a captured request cannot be replayed.
// Node.js
const crypto = require("crypto");
function isValidMessageWebhook(rawBody, secret, timestamp, signatureHeader) {
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!Number.isFinite(ageSeconds) || ageSeconds > 300) return false;
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// C#
static bool IsValidMessageWebhook(string rawBody, string secret, string timestamp, string signatureHeader)
{
if (!long.TryParse(timestamp, out long sentAt)
|| Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - sentAt) > 300)
return false;
byte[] key = Encoding.UTF8.GetBytes(secret);
byte[] hash = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
string expected = "sha256=" + Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signatureHeader));
}
Delivery and retries
- Your endpoint must answer with any
2xxstatus within 10 seconds. - A failed delivery is attempted up to 5 times in total, with retries after about 10 seconds, 30 seconds, 90 seconds and 5 minutes.
- After 50 consecutive failed deliveries an endpoint is deactivated. Re-activate it with
PUT /v1/webhooks/{webhookId}once the problem is fixed. - Deliveries may be repeated, so make your handler idempotent.
- You can catch up on replies you missed with
GET /v1/accounts/{accountId}/inbound-messages.
Validating signatures
All webhooks delivered to your endpoint are signed using HMAC-SHA256. Validate the signature before processing any payload.
// C# — timing-safe comparison
static bool IsValidSignature(string payload, string secret, string headerSig)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
string computed = Convert.ToHexString(hash).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(computed),
Encoding.UTF8.GetBytes(headerSig));
}
Use a timing-safe comparison (not == or string.Equals) to prevent signature oracle
attacks.
Idempotency
Webhooks may be delivered more than once. Design your handler to be idempotent:
- Store the event ID on first receipt and skip duplicate deliveries.
- Return
200 OKimmediately, then process asynchronously. - Confirm signature before any side-effects.
Reliability checklist
- Signature validation runs before payload processing
- Handlers return
200quickly and process asynchronously where needed - Event IDs are deduplicated
- Signing secrets are stored in environment variables, not source code
- Secrets are rotated immediately on suspected compromise