Skip to content

YeboSafe Webhook System

Complete documentation of the webhook delivery system.

Overview

YeboSafe sends HTTP POST webhooks to merchants when escrow events occur.

Webhook Events

EventTrigger
escrow.createdNew escrow created
escrow.acceptedMerchant accepts escrow
escrow.refusedMerchant refuses escrow
escrow.completedCompletion code used, funds released
escrow.disputedDispute opened
escrow.cancelledEscrow 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.

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

AttemptDelayTotal Time
1Immediate0s
22 seconds2s
34 seconds6s

After 3 failed attempts, the webhook is marked as failed.

Best Practices for Receivers

  1. Respond Quickly - Return 200 within 10 seconds
  2. Process Async - Queue events for background processing
  3. Verify Signature - Always validate X-YeboSafe-Signature
  4. Handle Duplicates - Use idempotent handlers (check event ID)
  5. 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;

One chat. Everything done.