YeboLink Webhooks Deep Dive
YeboLink provides a comprehensive webhook system for real-time event notifications. Customers can configure endpoints to receive delivery reports, inbound messages, and account events.
Webhook Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ Event Sources │
├─────────────────────────────────────────────────────────────────────┤
│ Message Sent │ Twilio Status │ Credit Change │ Inbound Msg │
└───────┬────────┴────────┬────────┴────────┬────────┴────────┬───────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ WebhookService.trigger() │
│ (finds active webhooks for event) │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ enqueueWebhook() │
│ (Cloud Tasks queue) │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ /internal/deliver-webhook │
│ (HTTP POST to customer) │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ Customer's Webhook Endpoint │
│ https://customer.com/webhook │
└─────────────────────────────────────────────────────────────────────┘Supported Events
typescript
// webhook.service.ts
static getSupportedEvents(): string[] {
return [
'message.sent',
'message.delivered',
'message.failed',
'message.received',
'credit.low',
'credit.depleted',
];
}Event Details
| Event | Trigger | Payload |
|---|---|---|
message.sent | Message queued for delivery | message_id, channel, recipient |
message.delivered | Provider confirms delivery | message_id, channel, recipient, timestamp |
message.failed | Delivery failed | message_id, channel, recipient, reason |
message.received | Inbound message received | from, to, content, channel |
credit.low | Balance drops below threshold | balance, threshold |
credit.depleted | Balance reaches zero | balance |
Webhook Management
Creating a Webhook
typescript
// webhook.service.ts
static async create(
workspaceId: string,
url: string,
events: string[]
): Promise<{ webhook: Webhook; secret: string }> {
// Validate URL
try {
new URL(url);
} catch (error) {
throw new Error('Invalid webhook URL');
}
// Generate signing secret (32 bytes = 64 hex chars)
const secret = crypto.randomBytes(32).toString('hex');
// Store hash of secret (never store plaintext)
const secretHash = crypto
.createHash('sha256')
.update(secret)
.digest('hex');
const webhook = await WebhookModel.create({
workspace_id: workspaceId,
url,
secret: secretHash,
events,
});
return {
webhook,
secret, // Return plain secret only once!
};
}API Response:
json
{
"success": true,
"data": {
"webhook": {
"id": "uuid",
"url": "https://myapp.com/webhook",
"events": ["message.sent", "message.delivered"],
"is_active": true,
"created_at": "2024-..."
},
"secret": "a3f2d1c4e5b6...",
"warning": "Save this secret securely. It will not be shown again."
}
}Updating a Webhook
typescript
static async update(
webhookId: string,
workspaceId: string,
data: {
url?: string;
events?: string[];
is_active?: boolean;
}
): Promise<Webhook | null> {
if (data.url) {
try {
new URL(data.url);
} catch (error) {
throw new Error('Invalid webhook URL');
}
}
return await WebhookModel.update(webhookId, workspaceId, data);
}Triggering Webhooks
When an event occurs, WebhookService.trigger() is called:
typescript
static async trigger(
workspaceId: string,
eventType: string,
payload: Record<string, any>
): Promise<void> {
// Find all active webhooks subscribed to this event
const webhooks = await WebhookModel.findActiveByEvent(workspaceId, eventType);
if (webhooks.length === 0) {
logger.debug('No webhooks found for event', { workspaceId, eventType });
return;
}
// Queue delivery for each webhook
for (const webhook of webhooks) {
await enqueueWebhook(webhook.id, eventType, payload);
logger.info('Webhook queued for delivery', {
webhookId: webhook.id,
eventType,
});
}
}Example: Message Delivery Status
typescript
// Called when Twilio sends status callback
await WebhookService.trigger(message.workspace_id, `message.${statusData.status}`, {
message_id: message.id,
channel: message.channel,
recipient: message.recipient,
status: statusData.status,
error_code: statusData.errorCode,
error_message: statusData.errorMessage,
timestamp: new Date().toISOString(),
});Webhook Delivery
Cloud Tasks Integration
typescript
// jobs/queues.ts
export async function enqueueWebhook(
webhookId: string,
eventType: string,
payload: any
) {
return enqueueTask({
queue: 'webhook-queue',
url: '/internal/deliver-webhook',
payload: { webhookId, eventType, payload },
});
}Delivery Worker
typescript
// routes/internal.routes.ts
router.post(
'/deliver-webhook',
asyncHandler(async (req: Request, res: Response) => {
const { webhookId, eventType, payload } = req.body;
logger.info('Delivering webhook', { webhookId, eventType });
const webhook = await WebhookModel.findById(webhookId);
if (!webhook || !webhook.is_active) {
logger.warn('Webhook not found or inactive', { webhookId });
res.status(200).json({ ok: true, skipped: true });
return;
}
// Build payload with metadata
const webhookPayload = {
id: crypto.randomUUID(),
type: eventType,
created_at: new Date().toISOString(),
data: payload,
};
const payloadJson = JSON.stringify(webhookPayload);
// Generate signature
const signature = WebhookService.generateSignature(
payloadJson,
webhook.secret // Note: This is the hash, not original secret
);
try {
const response = await axios.post(webhook.url, webhookPayload, {
headers: {
'Content-Type': 'application/json',
'X-YeboLink-Signature': signature,
'X-YeboLink-Event': eventType,
'X-YeboLink-Delivery-ID': webhookPayload.id,
},
timeout: 30000, // 30 second timeout
});
// Log successful delivery
await WebhookModel.createDelivery({
webhook_id: webhookId,
event_type: eventType,
payload: webhookPayload,
response_status: response.status,
response_body: JSON.stringify(response.data).slice(0, 1000),
});
// Reset failure count on success
await WebhookModel.resetFailureCount(webhookId);
await WebhookModel.updateLastTriggered(webhookId);
res.status(200).json({ ok: true });
} catch (error: any) {
// Log failed delivery
await WebhookModel.createDelivery({
webhook_id: webhookId,
event_type: eventType,
payload: webhookPayload,
response_status: error.response?.status,
error: error.message,
});
// Increment failure count
const failures = await WebhookModel.incrementFailureCount(webhookId);
// Disable webhook after 10 consecutive failures
if (failures >= 10) {
await WebhookModel.update(webhookId, webhook.workspace_id, { is_active: false });
logger.warn('Webhook disabled after 10 failures', { webhookId });
}
res.status(500).json({ ok: false, error: error.message });
}
})
);Signature Verification
YeboLink signs all webhook payloads with HMAC-SHA256.
Generating Signatures
typescript
static generateSignature(payload: string, secret: string): string {
return crypto.createHmac('sha256', secret).update(payload).digest('hex');
}Customer Verification (Example)
javascript
// Node.js example for customer
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
// Express handler
app.post('/webhook', express.json(), (req, res) => {
const signature = req.headers['x-yebolink-signature'];
const payload = JSON.stringify(req.body);
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Process webhook
console.log('Event:', req.headers['x-yebolink-event']);
console.log('Data:', req.body.data);
res.status(200).send('OK');
});Python Verification
python
import hmac
import hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
# Flask handler
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-YeboLink-Signature')
payload = request.data
if not verify_webhook(payload, signature, WEBHOOK_SECRET):
return 'Invalid signature', 401
data = request.json
event_type = request.headers.get('X-YeboLink-Event')
# Process webhook
print(f"Received {event_type}: {data['data']}")
return 'OK', 200Webhook Payload Format
Standard Envelope
json
{
"id": "del_abc123...",
"type": "message.delivered",
"created_at": "2024-03-19T12:30:00.000Z",
"data": {
// Event-specific data
}
}Event Payloads
message.sent
json
{
"id": "del_...",
"type": "message.sent",
"created_at": "2024-03-19T12:30:00.000Z",
"data": {
"message_id": "msg_uuid",
"channel": "sms",
"recipient": "+26878422613",
"provider_message_id": "SM123abc..."
}
}message.delivered
json
{
"id": "del_...",
"type": "message.delivered",
"created_at": "2024-03-19T12:30:05.000Z",
"data": {
"message_id": "msg_uuid",
"channel": "sms",
"recipient": "+26878422613",
"status": "delivered",
"delivered_at": "2024-03-19T12:30:05.000Z"
}
}message.failed
json
{
"id": "del_...",
"type": "message.failed",
"created_at": "2024-03-19T12:30:05.000Z",
"data": {
"message_id": "msg_uuid",
"channel": "sms",
"recipient": "+26878422613",
"status": "failed",
"error_code": "30003",
"error_message": "Unreachable destination handset"
}
}credit.low
json
{
"id": "del_...",
"type": "credit.low",
"created_at": "2024-03-19T12:30:00.000Z",
"data": {
"workspace_id": "ws_uuid",
"balance": 50,
"threshold": 100
}
}Delivery History
View recent webhook deliveries for debugging:
typescript
// GET /api/v1/webhooks/:id/deliveries
static async getDeliveries(webhookId: string, limit: number = 50) {
return await WebhookModel.getDeliveries(webhookId, limit);
}Response:
json
{
"success": true,
"data": {
"deliveries": [
{
"id": "del_uuid",
"event_type": "message.delivered",
"response_status": 200,
"response_body": "{\"received\":true}",
"error": null,
"delivered_at": "2024-03-19T12:30:05.000Z"
},
{
"id": "del_uuid2",
"event_type": "message.failed",
"response_status": 500,
"response_body": null,
"error": "Connection timeout",
"delivered_at": "2024-03-19T12:25:00.000Z"
}
]
}
}Twilio Inbound Webhooks
YeboLink receives webhooks from Twilio for:
- Status callbacks — Delivery status updates
- Inbound messages — Messages sent to our numbers
Status Callback Handler
typescript
router.post(
'/twilio/status',
asyncHandler(async (req: Request, res: Response) => {
// Validate Twilio signature
const signature = req.headers['x-twilio-signature'] as string;
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const isValid = TwilioService.validateWebhookSignature(
signature,
url,
req.body
);
if (!isValid) {
res.status(403).send('Invalid signature');
return;
}
// Parse status
const statusData = TwilioService.parseStatusCallback(req.body);
// Find message by Twilio SID
const message = await MessageModel.findByProviderMessageId(statusData.messageSid);
if (message) {
// Update status
await MessageModel.updateStatus(message.id, statusData.status as any, {
error_message: statusData.errorMessage,
});
// Trigger customer webhooks
await WebhookService.trigger(message.workspace_id, `message.${statusData.status}`, {
message_id: message.id,
channel: message.channel,
recipient: message.recipient,
status: statusData.status,
error_code: statusData.errorCode,
error_message: statusData.errorMessage,
});
}
res.status(200).send('OK');
})
);Inbound Message Handler
typescript
router.post(
'/twilio/inbound',
asyncHandler(async (req: Request, res: Response) => {
// Validate signature
const signature = req.headers['x-twilio-signature'] as string;
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const isValid = TwilioService.validateWebhookSignature(signature, url, req.body);
if (!isValid) {
res.status(403).send('Invalid signature');
return;
}
// Future: Process inbound message
// - Find workspace by receiving number
// - Create inbound message record
// - Trigger message.received webhook
res.status(200).send('OK');
})
);Best Practices
For Customers
- Always verify signatures before processing
- Respond quickly (< 5 seconds) with 2xx status
- Process asynchronously — queue the work, respond immediately
- Handle duplicates — webhooks may be retried
- Store the delivery ID for idempotency
For YeboLink
- Retry failed deliveries with exponential backoff
- Disable webhooks after repeated failures
- Log all attempts for debugging
- Sign all payloads to prevent tampering
- Timeout quickly (30s) to prevent queue blocking