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.