WebhooksConfiguration

Webhook Configuration

Register one or more webhook endpoints to receive real-time delivery updates and inbound messages. Each customer’s webhooks are isolated — they only receive events for that customer’s messages.

You can register any number of webhooks. Each webhook has its own URL, event subscription list, optional name, and its own signing secret. A single event is fanned out to every webhook that subscribed to that event type.

Webhooks are unique per (URL, event set) for a given customer — creating a second webhook with the same URL and exact same event list returns 409 WEBHOOK_DUPLICATE. Different URLs or different event sets are always allowed.

Register a Webhook

curl -X POST https://api.textbubbles.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "TextBubbles Events",
    "url": "https://your-server.com/webhooks/textbubbles",
    "events": [
      "message.queued",
      "message.sent",
      "message.delivered",
      "message.read",
      "message.failed",
      "message.inbound",
      "message.reaction",
      "typing.indicator"
    ]
  }'

Response:

{
  "success": true,
  "data": {
    "id": "b1a9e4e6-...-9f21",
    "name": "TextBubbles Events",
    "url": "https://your-server.com/webhooks/textbubbles",
    "events": ["message.delivered", "message.failed", "..."],
    "active": true,
    "secret": "whsec_generated_signing_secret_value",
    "createdAt": "2026-04-21T10:00:00.000Z",
    "updatedAt": "2026-04-21T10:00:00.000Z"
  },
  "requestId": "req_abc123"
}

The secret field is returned only on creation and only when you did not supply one. Store it immediately — it cannot be retrieved later. If you need a new secret, call Rotate Signing Secret.

If you want to manage the secret yourself, pass "secret": "your_own_value" in the request body. The API stores it but does not echo it back.

Body fields

FieldRequiredDescription
urlyesHTTPS URL to receive webhook POST requests
eventsyesArray of event types to subscribe to. Use ["*"] to subscribe to all current and future events
namenoHuman-readable label, up to 100 characters
secretnoYour own HMAC-SHA256 signing secret. If omitted, a random one is generated and returned once

Wildcard Events

Subscribe to every event type with *:

curl -X POST https://api.textbubbles.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-server.com/webhooks/textbubbles",
    "events": ["*"]
  }'

List Your Webhooks

curl https://api.textbubbles.com/v1/webhooks/list \
  -H "Authorization: Bearer YOUR_API_KEY"

Response:

{
  "success": true,
  "data": [
    {
      "id": "b1a9e4e6-...-9f21",
      "name": "TextBubbles Events",
      "url": "https://your-server.com/webhooks/textbubbles",
      "events": ["message.delivered", "message.failed"],
      "active": true,
      "createdAt": "2026-04-21T10:00:00.000Z",
      "updatedAt": "2026-04-21T10:00:00.000Z"
    },
    {
      "id": "c8d2f1a0-...-3c74",
      "name": null,
      "url": "https://your-server.com/webhooks/inbound",
      "events": ["message.inbound"],
      "active": true,
      "createdAt": "2026-04-21T11:00:00.000Z",
      "updatedAt": "2026-04-21T11:00:00.000Z"
    }
  ],
  "requestId": "req_abc123"
}

Secrets are never returned in list or get responses.

Get a Single Webhook

curl https://api.textbubbles.com/v1/webhooks/{id} \
  -H "Authorization: Bearer YOUR_API_KEY"

Returns 404 WEBHOOK_NOT_FOUND if the webhook does not exist or does not belong to your customer.

Update a Webhook

Partially update a webhook. All body fields are optional; at least one must be provided.

curl -X PATCH https://api.textbubbles.com/v1/webhooks/{id} \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": ["message.delivered", "message.failed", "message.inbound"]
  }'

Updatable fields: url, events, secret, name, active.

Setting "active": false pauses the webhook — it stays in your list but receives no events. Set "active": true to resume. Use this when troubleshooting a downstream outage instead of deleting and re-creating.

Returns 409 WEBHOOK_DUPLICATE if the update would make the webhook collide with another active one on (url, events). Returns 404 WEBHOOK_NOT_FOUND if the webhook does not belong to your customer.

Delete a Webhook

curl -X DELETE https://api.textbubbles.com/v1/webhooks/{id} \
  -H "Authorization: Bearer YOUR_API_KEY"

Returns 404 WEBHOOK_NOT_FOUND if the webhook does not exist or does not belong to your customer.

Send a Test Event

Trigger a synthetic webhook.test event delivered to one specific webhook. Useful for verifying connectivity, TLS, and signature verification on your endpoint without waiting for real traffic.

curl -X POST https://api.textbubbles.com/v1/webhooks/{id}/test \
  -H "Authorization: Bearer YOUR_API_KEY"

The test event delivered to your endpoint has this shape:

{
  "id": "evt_test_550e8400-e29b-41d4-a716-446655440000",
  "type": "webhook.test",
  "timestamp": "2026-04-21T10:00:00.000Z",
  "data": {
    "message": "This is a test event from TextBubbles...",
    "webhookId": "b1a9e4e6-...-9f21"
  }
}

The request is signed the same way as production events, so you can use it to end-to-end test your signature verification code.

Rotate Signing Secret

Generate a new signing secret for a specific webhook. The old secret is immediately invalidated — update your endpoint with the new secret before rotating, or be prepared for a brief verification gap.

curl -X POST https://api.textbubbles.com/v1/webhooks/{id}/rotate-secret \
  -H "Authorization: Bearer YOUR_API_KEY"

Response:

{
  "success": true,
  "data": {
    "id": "b1a9e4e6-...-9f21",
    "name": "TextBubbles Events",
    "url": "https://your-server.com/webhooks/textbubbles",
    "events": ["message.delivered"],
    "active": true,
    "secret": "whsec_new_generated_secret_value",
    "createdAt": "2026-04-21T10:00:00.000Z",
    "updatedAt": "2026-04-21T12:30:00.000Z"
  },
  "requestId": "req_abc123"
}

Important: The secret value is returned only once in this response. Store it immediately — it cannot be retrieved later. If you lose it, call this endpoint again to rotate a new one.

Returns 404 WEBHOOK_NOT_FOUND if the webhook does not exist or does not belong to your customer.

Signature Verification

Every webhook request includes two headers:

  • X-Signaturesha256={hmac_hex} HMAC-SHA256 signature (lowercase hex, prefixed with sha256=)
  • X-Timestamp — Unix timestamp in seconds (not milliseconds) when the webhook was signed

Each webhook has its own signing secret — the secret you supplied at creation time, or the one returned to you when the API generated one on your behalf. Signature computations for that webhook always use that webhook’s secret, so different webhooks can have independent secrets safely.

Correlation header

Every webhook delivery also includes:

  • X-Correlation-Idcor_{32 hex chars} correlation identifier that ties this webhook back to the originating message lifecycle.

The same identifier is also accepted on every API request (you can supply your own, or one is minted for you) and echoed back on every API response, so you can stitch your logs to ours end-to-end:

You POST /v1/messages with -H 'X-Correlation-Id: cor_abc...'
   ↓ same id on the response
   ↓ same id on the message.queued / message.sent / message.delivered webhooks back to you

Treat it as an opaque string. If you supply one, it must match the cor_<32 hex chars> shape — anything else is replaced with a freshly minted id.

Verification Steps

  1. Extract the timestamp and signature from the headers
  2. Read the exact raw request body bytes — do NOT parse and re-serialize JSON, as key order or whitespace differences will break the signature
  3. Construct the signed payload string: {timestamp}.{raw_request_body} (a literal . between the timestamp and body)
  4. Compute HMAC-SHA256(secret, signedPayload) and hex-encode the digest
  5. Prepend sha256= and compare to X-Signature using constant-time comparison
  6. Reject requests where |now - timestamp| > 300 seconds to prevent replay attacks

Algorithm Reference

signedPayload = timestamp + "." + rawRequestBody
expected      = "sha256=" + lowerHex(HMAC_SHA256(secret, signedPayload))
valid         = constantTimeEqual(expected, X-Signature) AND abs(now - timestamp) <= 300

Unit Test Fixture

You can use this known-good fixture to verify your implementation. If your code produces the expected signature below, your verifier is correct.

FieldValue
secretwhsec_test_secret_do_not_use_in_production
X-Timestamp1774699203
Raw body{"id":"evt_550e8400-e29b-41d4-a716-446655440000","type":"message.delivered","timestamp":"2026-03-28T10:00:03.000Z","data":{"messageId":"msg_xyz","externalMessageId":"external-guid","from":"+19876543210","to":"+14155551234","text":"Hello!","channel":"imessage","status":"delivered"}}
Expected X-Signaturesha256=d055c034071c12e906654f864c1e5a03fbdea2399444cdf4448f35bf81218977
import crypto from 'crypto';
 
const secret = 'whsec_test_secret_do_not_use_in_production';
const timestamp = '1774699203';
const rawBody = '{"id":"evt_550e8400-e29b-41d4-a716-446655440000","type":"message.delivered","timestamp":"2026-03-28T10:00:03.000Z","data":{"messageId":"msg_xyz","externalMessageId":"external-guid","from":"+19876543210","to":"+14155551234","text":"Hello!","channel":"imessage","status":"delivered"}}';
 
const signedPayload = `${timestamp}.${rawBody}`;
const signature = 'sha256=' + crypto
  .createHmac('sha256', secret)
  .update(signedPayload)
  .digest('hex');
 
console.log(signature);
// → sha256=d055c034071c12e906654f864c1e5a03fbdea2399444cdf4448f35bf81218977

Node.js Example (Express)

The critical detail: capture the raw body buffer before express.json() parses it. Verifying against a re-serialized req.body will fail.

import crypto from 'crypto';
import express from 'express';
 
const app = express();
 
// Capture raw body bytes for signature verification
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); }
}));
 
const WEBHOOK_SECRET = process.env.TEXTBUBBLES_WEBHOOK_SECRET;
 
function verifySignature(req) {
  const signature = req.headers['x-signature'];
  const timestamp = req.headers['x-timestamp'];
 
  if (!signature || !timestamp) return false;
 
  // Reject requests older than 5 minutes (timestamp is in seconds)
  const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
  if (age > 300) return false;
 
  // Use the raw body string, NOT JSON.stringify(req.body)
  const signedPayload = `${timestamp}.${req.rawBody}`;
  const expected = 'sha256=' + crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload)
    .digest('hex');
 
  // Constant-time comparison — both buffers must be the same length
  const sigBuf = Buffer.from(signature);
  const expBuf = Buffer.from(expected);
  if (sigBuf.length !== expBuf.length) return false;
  return crypto.timingSafeEqual(sigBuf, expBuf);
}
 
app.post('/webhooks/textbubbles', (req, res) => {
  if (!verifySignature(req)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
 
  const { type, data } = req.body;
 
  switch (type) {
    case 'message.delivered':
      console.log(`Message ${data.messageId} delivered via ${data.channel}`);
      break;
    case 'message.failed':
      console.log(`Message ${data.messageId} failed: ${data.status}`);
      break;
    case 'message.inbound':
      console.log(`Inbound from ${data.from}: ${data.text}`);
      break;
  }
 
  res.status(200).json({ received: true });
});
 
app.listen(3000);

Python Example

import hmac
import hashlib
import time
 
def verify_webhook(raw_body: str, signature: str, timestamp: str, secret: str) -> bool:
    """
    raw_body: the exact request body as a string (do NOT re-serialize parsed JSON)
    signature: value of the X-Signature header, e.g. "sha256=abc123..."
    timestamp: value of the X-Timestamp header (unix seconds, as a string)
    secret: the signing secret for this webhook
    """
    age = abs(time.time() - int(timestamp))
    if age > 300:
        return False
 
    signed_payload = f"{timestamp}.{raw_body}"
    expected = "sha256=" + hmac.new(
        secret.encode(), signed_payload.encode(), hashlib.sha256
    ).hexdigest()
 
    return hmac.compare_digest(signature, expected)

Common Verification Pitfalls

  • Re-serializing parsed JSON. JSON.stringify(req.body) or json.dumps(request.json()) will almost never reproduce the exact bytes the API signed. Always verify against the raw body.
  • Wrong separator. The signed string is {timestamp}.{body} — a literal ASCII period between timestamp and body, with no spaces.
  • Millisecond vs second timestamp. X-Timestamp is Unix seconds. Dividing Date.now() by 1000 and flooring gives the right unit.
  • Forgetting the sha256= prefix in the comparison string.
  • Using the wrong webhook’s secret. If you register multiple webhooks that point at different endpoints, each one has its own secret. Verify each endpoint using the secret you received when creating (or rotating) that specific webhook.

Retry Policy

Failed webhook deliveries are retried with exponential backoff:

AttemptDelay
130 seconds
25 minutes
330 minutes
42 hours
58 hours
624 hours
724 hours (final)

Your endpoint must respond with a 2xx status code within 30 seconds.

Best Practices

  • Return 200 quickly — process events asynchronously in a background job
  • Handle duplicates — use the id field to deduplicate events
  • Verify signatures — always validate X-Signature before processing
  • Use per-message callbacks — for routing to different systems, use the callbackUrl field on individual messages. Or register a webhook with a narrower event subscription for the route you care about
  • Pause, don’t delete — if a downstream system is temporarily unhealthy, set active: false on the affected webhook instead of deleting it, so the registration and secret are preserved

Legacy Single-Webhook Endpoints

The older single-webhook endpoints continue to work but are deprecated. They operate against your first active webhook (oldest createdAt) and are kept so that existing integrations keep working.

DeprecatedReplacement
PUT /v1/webhooks (replaces all active webhooks with one)POST /v1/webhooks
GET /v1/webhooks (returns first active or null)GET /v1/webhooks/list
DELETE /v1/webhooks (deactivates all active webhooks)DELETE /v1/webhooks/{id}
POST /v1/webhooks/test (first active webhook)POST /v1/webhooks/{id}/test
POST /v1/webhooks/rotate-secret (first active webhook)POST /v1/webhooks/{id}/rotate-secret

New integrations should use the id-scoped endpoints above.