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
| Field | Required | Description |
|---|---|---|
url | yes | HTTPS URL to receive webhook POST requests |
events | yes | Array of event types to subscribe to. Use ["*"] to subscribe to all current and future events |
name | no | Human-readable label, up to 100 characters |
secret | no | Your 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
secretvalue 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-Signature—sha256={hmac_hex}HMAC-SHA256 signature (lowercase hex, prefixed withsha256=)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-Id—cor_{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 youTreat 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
- Extract the timestamp and signature from the headers
- Read the exact raw request body bytes — do NOT parse and re-serialize JSON, as key order or whitespace differences will break the signature
- Construct the signed payload string:
{timestamp}.{raw_request_body}(a literal.between the timestamp and body) - Compute
HMAC-SHA256(secret, signedPayload)and hex-encode the digest - Prepend
sha256=and compare toX-Signatureusing constant-time comparison - Reject requests where
|now - timestamp| > 300seconds 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) <= 300Unit 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.
| Field | Value |
|---|---|
secret | whsec_test_secret_do_not_use_in_production |
X-Timestamp | 1774699203 |
| 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-Signature | sha256=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=d055c034071c12e906654f864c1e5a03fbdea2399444cdf4448f35bf81218977Node.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)orjson.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-Timestampis Unix seconds. DividingDate.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:
| Attempt | Delay |
|---|---|
| 1 | 30 seconds |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
| 6 | 24 hours |
| 7 | 24 hours (final) |
Your endpoint must respond with a 2xx status code within 30 seconds.
Best Practices
- Return
200quickly — process events asynchronously in a background job - Handle duplicates — use the
idfield to deduplicate events - Verify signatures — always validate
X-Signaturebefore processing - Use per-message callbacks — for routing to different systems, use the
callbackUrlfield 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: falseon 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.
| Deprecated | Replacement |
|---|---|
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.