4
Handling Webhooks

SaligPay sends webhook notifications to update you about payment status changes. Set up a webhook endpoint to receive these updates and update your database accordingly.

Webhook Payload Structure

The webhook payload contains comprehensive information about the payment transaction and its current status:

Sample Webhook Payload
{
  "id": "txn_456def...",
  "externalId": "ext_20250523_xyz123...",
  "amount": 10000,
  "description": "Premium Subscription",
  "paymentMethod": {
    "type": "credit_card"
  },
  "paymentSessionId": "session_123abc...",
  "status": "SUCCESS",
  "contact": {
    "email": "customer@example.com",
    "phoneNumber": "1234567890",
    "name": "John Doe"
  },
  "creditCardDetails": {
    "cardNumber": "****1234",
    "expiryMonth": "12",
    "expiryYear": "2025",
    "cardholderName": "John Doe"
  }
}

Webhook Handler Implementation

Implement a webhook handler to process these notifications:

Webhook Handler
// In your webhook route handler
import { type ActionFunctionArgs } from "react-router";
import { PrismaClient, PaymentStatus } from "@prisma/client";

const prisma = new PrismaClient();

interface CreditCardDto {
    cardNumber?: string;
    expiryMonth?: string;
    expiryYear?: string;
    cardholderName?: string;
}

interface ContactDto {
    email?: string;
    phoneNumber?: string;
    name?: string;
}

interface PaymentMethodDto {
    type: string;
}

interface TransactionDataDto {
    id?: string;
    externalId?: string;
    amount: number;
    description?: string;
    paymentMethod: PaymentMethodDto;
    paymentSessionId?: string;
    status?: string;
    contact?: ContactDto;
    creditCardDetails?: CreditCardDto;
}

export const action = async ({ request }: ActionFunctionArgs) => {
    if (request.method !== "POST") {
        return {
            error: "Method not allowed",
            status: 405,
        };
    }

    try {
        const payload = (await request.json()) as TransactionDataDto;
        console.log("Webhook received:", JSON.stringify(payload, null, 2));

        if (!payload.amount || !payload.externalId) {
            console.error("Missing required fields in payload:", payload);
            return {
                error: "Missing required fields",
                status: 400,
            };
        }

        const existingPayment = await prisma.payment.findUnique({
            where: { externalId: payload.externalId },
            include: { statusHistory: true },
        });

        if (!existingPayment) {
            console.error(
                `Payment with externalId ${payload.externalId} not found`
            );
            return {
                error: `Payment with externalId ${payload.externalId} not found`,
                status: 404,
            };
        }

        console.log(
            `Processing webhook for payment ${existingPayment.id}, current status: ${existingPayment.status}`
        );
        console.log(`Incoming webhook status: ${payload.status}`);

        let paymentStatus: PaymentStatus;
        const incomingStatus = (payload.status || "").toUpperCase();

        switch (incomingStatus) {
            case "COMPLETED":
            case "SUCCESS":
            case "PAID":
                paymentStatus = PaymentStatus.COMPLETED;
                break;
            case "FAILED":
            case "FAILURE":
            case "ERROR":
                paymentStatus = PaymentStatus.FAILED;
                break;
            case "REFUNDED":
                paymentStatus = PaymentStatus.REFUNDED;
                break;
            case "CANCELLED":
            case "CANCELED":
                paymentStatus = PaymentStatus.CANCELLED;
                break;
            case "PROCESSING":
            case "PENDING":
                paymentStatus = PaymentStatus.PROCESSING;
                break;
            default:
                console.error(`Unknown payment status: ${payload.status}`);
                return {
                    error: `Unknown payment status: ${payload.status}`,
                    status: 400,
                };
        }

        // Skip updating if the status hasn't changed
        if (existingPayment.status === paymentStatus) {
            console.log(`Payment status unchanged, still ${paymentStatus}`);
            return {
                message: "Payment status unchanged",
                status: 200,
            };
        }

        // Update the payment status
        await prisma.payment.update({
            where: { id: existingPayment.id },
            data: {
                status: paymentStatus,
                updatedAt: new Date(),
                statusHistory: {
                    create: {
                        status: paymentStatus,
                        timestamp: new Date(),
                        metadata: JSON.stringify(payload),
                    },
                },
            },
        });

        console.log(`Payment updated successfully, new status: ${paymentStatus}`);

        // Trigger other relevant business logic based on status change
        if (paymentStatus === PaymentStatus.COMPLETED) {
            // Handle successful payment (e.g., fulfill order, send confirmation, etc.)
            await fulfillOrder(existingPayment.id);
        } else if (paymentStatus === PaymentStatus.FAILED) {
            // Handle failed payment (e.g., notify customer, update inventory, etc.)
            await handleFailedPayment(existingPayment.id);
        }

        return {
            message: "Webhook processed successfully",
            status: 200,
        };
    } catch (error) {
        console.error("Error processing webhook:", error);
        return {
            error: "Error processing webhook",
            details: error instanceof Error ? error.message : String(error),
            status: 500,
        };
    }
};

// Example of other functions that might be triggered by webhook status changes
async function fulfillOrder(paymentId: string) {
    console.log(`Fulfilling order for payment: ${paymentId}`);
    // Implementation details...
}

async function handleFailedPayment(paymentId: string) {
    console.log(`Handling failed payment: ${paymentId}`);
    // Implementation details...
}

Security Tip: Always verify webhook signatures to ensure the request comes from SaligPay and hasn't been tampered with.

Webhook Signature Verification

To ensure webhook security, verify the signature:

Webhook Signature Verification
// Verify the webhook signature to ensure it came from SaligPay
function verifyWebhookSignature(
  payload: string, 
  signature: string, 
  secret: string
): boolean {
  const crypto = require('crypto');
  
  // Generate an HMAC with SHA256
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(payload);
  const expectedSignature = hmac.digest('hex');
  
  // Use crypto.timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature), 
    Buffer.from(expectedSignature)
  );
}

// Usage in your webhook handler
export const action = async ({ request }: ActionFunctionArgs) => {
  // Get the payload as text first
  const rawBody = await request.text();
  
  // Get the signature from header
  const signature = request.headers.get('saligpay-signature');
  
  // Verify the signature
  if (!signature || !verifyWebhookSignature(
      rawBody, 
      signature, 
      process.env.WEBHOOK_SECRET
    )) {
    return new Response('Invalid signature', { status: 401 });
  }
  
  // If verification passes, proceed with processing the webhook
  const payload = JSON.parse(rawBody);
  
  // Process the webhook payload
  // ...
}