YeboSafe Webhook System
Complete documentation of the webhook delivery system.
Overview
YeboSafe sends HTTP POST webhooks to merchants when escrow events occur.
Webhook Events
| Event | Trigger |
|---|---|
escrow.created | New escrow created |
escrow.accepted | Merchant accepts escrow |
escrow.refused | Merchant refuses escrow |
escrow.completed | Completion code used, funds released |
escrow.disputed | Dispute opened |
escrow.cancelled | Escrow cancelled |
Payload Structure
json
{
"event": "escrow.completed",
"data": {
"id": "clx123abc",
"reference": "clx123abc",
"amount": "100.00",
"currency": "USD",
"status": "COMPLETED"
},
"timestamp": "2024-01-15T10:30:00.000Z"
}Signature Verification
All webhooks are signed with HMAC-SHA256.
Header
X-YeboSafe-Signature: <signature>Verification (Node.js)
typescript
import crypto from 'crypto';
function verifyWebhookSignature(
rawBody: string,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Express handler
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-yebosafe-signature'] as string;
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(rawBody);
switch (event.event) {
case 'escrow.completed':
// Handle completion
break;
case 'escrow.disputed':
// Handle dispute
break;
}
res.status(200).send('OK');
});WebhookService Implementation
Location: yebosafe-api/src/services/webhook.service.ts
Signing
typescript
private static sign(payload: string, secret: string): string {
return crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
}Fire Event
typescript
static async fire(
merchantId: string,
event: string,
data: Record<string, unknown>
) {
// Get merchant webhook URL
const merchant = await prisma.merchant.findUnique({
where: { id: merchantId }
});
if (!merchant?.webhookUrl) return;
// Build payload
const payload = {
event,
data: {
id: data.id,
reference: data.reference,
amount: data.amount,
currency: data.currency,
status: data.status,
},
timestamp: new Date().toISOString(),
};
const payloadStr = JSON.stringify(payload);
const signature = this.sign(payloadStr, process.env.JWT_SECRET!);
// Record event
const webhookEvent = await prisma.webhookEvent.create({
data: {
merchantId,
event,
payload: payload as any,
},
});
// Deliver async
this.deliver(webhookEvent.id, merchant.webhookUrl, payloadStr, signature)
.catch(console.error);
}Delivery with Retry
typescript
private static async deliver(
eventId: string,
url: string,
payloadStr: string,
signature: string,
attempt = 1
): Promise<void> {
try {
await this.post(url, payloadStr, signature);
// Mark as delivered
await prisma.webhookEvent.update({
where: { id: eventId },
data: { delivered: true, attempts: attempt },
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
// Log attempt
await prisma.webhookEvent.update({
where: { id: eventId },
data: { attempts: attempt, lastError: errorMessage },
});
// Retry up to 3 times with exponential backoff
if (attempt < 3) {
const delay = Math.pow(2, attempt) * 1000; // 2s, 4s, 8s
await new Promise(r => setTimeout(r, delay));
return this.deliver(eventId, url, payloadStr, signature, attempt + 1);
}
}
}HTTP POST
typescript
private static post(
url: string,
body: string,
signature: string
): Promise<void> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const lib = parsed.protocol === 'https:' ? https : http;
const options = {
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-YeboSafe-Signature': signature,
},
};
const req = lib.request(options, (res) => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve();
} else {
reject(new Error(`HTTP ${res.statusCode}`));
}
res.resume();
});
req.on('error', reject);
req.setTimeout(10000, () => {
req.destroy(new Error('Webhook timeout'));
});
req.write(body);
req.end();
});
}Retry Strategy
| Attempt | Delay | Total Time |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 2 seconds | 2s |
| 3 | 4 seconds | 6s |
After 3 failed attempts, the webhook is marked as failed.
Best Practices for Receivers
- Respond Quickly - Return 200 within 10 seconds
- Process Async - Queue events for background processing
- Verify Signature - Always validate
X-YeboSafe-Signature - Handle Duplicates - Use idempotent handlers (check event ID)
- Return 200 - Even if processing fails (log and retry internally)
Example Handlers
Escrow Completed
typescript
case 'escrow.completed':
const { id, amount, currency } = event.data;
// Update your order status
await db.orders.update({
where: { escrowId: id },
data: { status: 'PAID', paidAmount: amount }
});
// Send confirmation email
await sendEmail('order-confirmed', order.customerEmail);
break;Escrow Disputed
typescript
case 'escrow.disputed':
const { id, reference } = event.data;
// Alert support team
await notifySupport({
type: 'ESCROW_DISPUTE',
escrowId: id,
reference
});
// Pause order fulfillment
await db.orders.update({
where: { escrowId: id },
data: { status: 'DISPUTED' }
});
break;