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.

  • url must be an HTTPS URL.
  • subscribedEvents is a comma-separated list of the event names below.
  • subaccountId is optional. An endpoint scoped to a subaccount receives that subaccount's events, plus events that belong to no subaccount.
Endpoint Purpose
GET /v1/webhooksList 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-secretIssue a new signing secret
POST /v1/webhooks/{webhookId}/testSend a test event
GET /v1/webhooks/{webhookId}/deliveriesSee delivery attempts
POST /v1/webhooks/{webhookId}/deliveries/{deliveryLogId}/replaySend a delivery again

Events

The eventType in each delivery is one of:

eventType Sent when
MessageAcceptedA message was accepted for delivery
MessageDeliveredThe carrier confirmed delivery
MessageFailedDelivery failed
MessageRejectedA message was rejected and will not be delivered
MessageDeadLetteredA message ran out of retries
MessageExpiredA message passed its expiresAt and was not sent
MessageReceivedA recipient replied to one of your numbers
RecipientOptedOutA recipient's opt-out changed to opted out
RecipientOptedInA 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:

  • messageId is the message the reply answers, or null when the reply could not be linked to one.
  • data.inReplyTo carries that message's id and your clientReference (or is null when unlinked). This is how you match a reply to an appointment or order without looking anything up.
  • data.keyword is Stop, Start or Help when the reply was an opt-out or opt-in keyword, otherwise null. Opt-outs are applied by ArelHub regardless; you receive the reply as information.
  • data.reply is present only when the message the reply answers offered reply options, and is null otherwise.
    • { "matched": true, "key": "C", "value": "confirm" } means the recipient chose that option. Act on value.
    • { "matched": false } means the text was not one of the options (for example yes please). The text is still in data.body, so you can handle it yourself.
    • A reply that is an opt-out, opt-in or help word is never an answer.
    • "afterExpiry": true is added when the reply arrived after the message's expiresAt. The reply is still delivered, but it does not answer the message.
  • Use data.inboundMessageId to 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-IdId of the delivery
X-Webhook-TimestampUnix time, in seconds, when the delivery was signed
X-Webhook-Signaturesha256= 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 2xx status 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 OK immediately, then process asynchronously.
  • Confirm signature before any side-effects.

Reliability checklist

  • Signature validation runs before payload processing
  • Handlers return 200 quickly 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

← Messaging  ·  Errors & Limits →