Webhooks & Callbacks

LakiPay sends real-time notifications to your callback URL when transaction status changes.

Overview

When a deposit or withdrawal status changes, LakiPay sends a POST request with Content-Type: application/json to the callback URL you provided on the transaction.

Event Types

DEPOSIT, WITHDRAWAL

Security

RSA-2048 signature verification (SHA-256, PKCS#1 v1.5)

Webhook Events

Event Types

DEPOSIT

Payment / deposit transactions

WITHDRAWAL

Withdrawal transactions

Webhook Payload

json
{
  "event": "WITHDRAWAL",
  "referenceId": "WD123456789",
  "lakipayTxnId": "c3d6de06-aec5-47f9-9dd9-1a1526c520e5",
  "status": "SUCCESS",
  "amount": "500.000000",
  "callbackUrl": "https://example.com/callback",
  "message": "Process service request successfully.",
  "providerTxId": "CET0KDL1Q4",
  "timestamp": "2025-01-15T10:08:56.220277271Z",
  "merchantId": "d5a577e7-17b4-4e74-9bd3-a3e6af1f1d41",
  "userId": "48dbfef3-9318-4cb2-9b87-e69a5d911e11",
  "signature": "Q3ghkA3T5k9pcby+F2NfHEafLeyVBSorpdusGSLaewCZSIPX0NniqQEZAyghd+Nd..."
}
FieldDescription
eventDEPOSIT or WITHDRAWAL
referenceIdYour merchant reference for the transaction
lakipayTxnIdLakiPay transaction ID (use for idempotency)
statusSUCCESS, FAILED, or PENDING
amountTransaction amount as a string
callbackUrlThe callback URL that received this webhook
messageHuman-readable status message
providerTxIdUpstream provider transaction ID
timestampEvent timestamp (ISO 8601)
merchantIdYour LakiPay merchant ID
userIdAssociated user ID
signatureBase64-encoded RSA-SHA256 signature of the payload

Status Values

SUCCESS

Transaction completed successfully

FAILED

Transaction failed

PENDING

Transaction in progress

Important Notes

  • HTTPS required — callback URLs must use HTTPS
  • Response expected — return HTTP 200 to acknowledge receipt
  • Retry logic — failed deliveries will be retried
  • Idempotency — handle duplicate notifications using lakipayTxnId

Example Handler (Node.js)

snippet
app.post('/webhook', (req, res) => {
  const { event, status, referenceId, lakipayTxnId } = req.body;

  // Process the webhook
  console.log(`${event} transaction ${referenceId}: ${status}`);

  // Acknowledge receipt
  res.status(200).send('OK');
});

Webhook Signature Verification

Security Requirement

LakiPay uses RSA-2048 encryption to sign all webhook payloads. Always verify the signature before processing to confirm the webhook is authentic and has not been tampered with.

Getting Your Public Key

  1. 1Log in to your LakiPay merchant dashboard
  2. 2Navigate to Settings → Security → Security Keys
  3. 3Download your public key (PEM format)
  4. 4Rotate keys from the same location when needed by regenerating a new public key

How It Works

  1. 1LakiPay creates a canonical string from the webhook payload by extracting all fields except signature, sorting fields alphabetically by key name, and formatting as key=value pairs joined by &
  2. 2LakiPay hashes this string using SHA-256
  3. 3LakiPay signs the hash using RSA with PKCS#1 v1.5 padding
  4. 4The signature is base64-encoded and included in the webhook payload

When you receive a webhook, perform the same steps and verify the signature matches using your public key.

Example Signed Webhook Payload

json
{
  "amount": "0.970000",
  "callbackUrl": "https://webhook.site/c4b766e9-5f83-4081-9404-a79575270315",
  "event": "DEPOSIT",
  "lakipayTxnId": "c65452ba-73c8-480a-9a87-c924448a4133",
  "merchantId": "bcb55506-0777-456a-8c1a-b4615bf6682d",
  "message": "Process service request successfully.",
  "providerTxId": "CJD7FSBRER",
  "referenceId": "61fasdgdag22fh1fdaaa880",
  "signature": "Q3ghkA3T5k9pcby+F2NfHEafLeyVBSorpdusGSLaewCZSIPX0NniqQEZAyghd+NdUIPW/r/d2kpq+5+sIhOHbe/lp4wLLuBQ2DIDqD/HPLYCOouZONcDWizGEeTn0tE+/sZrySIWWgLXFVaFo2ME2x4OLyiSFoYIUDJa4eZysSfVSIkuYiaOcriuScWfLHiRaeF1UZ3kl1fxzTEnsjRi9A+Nqqg9YUbTolJ8WoQz2q+SI6pmZ6JPHjCGea0df56JztF7kj91NfB5EJkVmG7K565u5+CRsgxt33VoL1mPpqF9VtWHll0KBsvoH4BBxVo1DvmKMcE3WEyPay+uu1aApg==",
  "status": "SUCCESS",
  "timestamp": "2025-10-13T01:52:06.102109294Z",
  "userId": "f9a6f163-481e-4573-bda2-76edb609a5e6"
}

Verification Examples

Node.js

code
const crypto = require('crypto');

function createCanonicalString(payload) {
  const fields = Object.keys(payload)
    .filter(key => key !== 'signature')
    .sort();

  const parts = [];
  for (const field of fields) {
    if (payload[field] !== undefined && payload[field] !== null) {
      parts.push(`${field}=${payload[field]}`);
    }
  }
  return parts.join('&');
}

function verifySignature(payload, publicKeyPEM) {
  try {
    const canonicalString = createCanonicalString(payload);
    const signature = payload.signature;
    const signatureBuffer = Buffer.from(signature, 'base64');
    const verifier = crypto.createVerify('RSA-SHA256');
    verifier.update(canonicalString);
    return verifier.verify(publicKeyPEM, signatureBuffer);
  } catch (error) {
    console.error('Signature verification failed:', error.message);
    return false;
  }
}

// Usage in your webhook handler
app.post('/webhook', (req, res) => {
  const publicKey = process.env.LAKIPAY_PUBLIC_KEY;

  if (!verifySignature(req.body, publicKey)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Process the webhook
  res.json({ success: true });
});

Java

code
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;
import java.util.stream.Collectors;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class WebhookVerifier {
  public static String createCanonicalString(Map<String, Object> payload) {
    return payload.keySet().stream()
      .filter(key -> !key.equals("signature"))
      .sorted()
      .map(key -> key + "=" + payload.get(key).toString())
      .collect(Collectors.joining("&"));
  }

  public static boolean verifySignature(Map<String, Object> payload, String publicKeyPEM) {
    try {
      String canonicalString = createCanonicalString(payload);
      String signatureStr = (String) payload.get("signature");
      byte[] signatureBytes = Base64.getDecoder().decode(signatureStr);

      String publicKeyContent = publicKeyPEM
        .replace("-----BEGIN PUBLIC KEY-----", "")
        .replace("-----END PUBLIC KEY-----", "")
        .replaceAll("\\s", "");
      byte[] decodedKey = Base64.getDecoder().decode(publicKeyContent);
      X509EncodedKeySpec spec = new X509EncodedKeySpec(decodedKey);
      KeyFactory kf = KeyFactory.getInstance("RSA");
      PublicKey publicKey = kf.generatePublic(spec);

      Signature sig = Signature.getInstance("SHA256withRSA");
      sig.initVerify(publicKey);
      sig.update(canonicalString.getBytes(StandardCharsets.UTF_8));
      return sig.verify(signatureBytes);
    } catch (Exception e) {
      System.err.println("Signature verification failed: " + e.getMessage());
      return false;
    }
  }
}

// Usage in your webhook controller
@PostMapping("/webhook")
public ResponseEntity<?> handleWebhook(@RequestBody Map<String, Object> payload) {
  String publicKey = System.getenv("LAKIPAY_PUBLIC_KEY");
  if (!WebhookVerifier.verifySignature(payload, publicKey)) {
    return ResponseEntity.status(401).body(Map.of("error", "Invalid signature"));
  }
  return ResponseEntity.ok(Map.of("success", true));
}

Laravel

snippet
<?php

namespace App\Services;

class LakiPayWebhookVerifier
{
  public static function createCanonicalString(array $payload)
  {
    $data = array_filter(
      $payload,
      fn($key) => $key !== 'signature',
      ARRAY_FILTER_USE_KEY
    );
    ksort($data);

    $parts = [];
    foreach ($data as $key => $value) {
      if ($value !== null) {
        $parts[] = $key . '=' . $value;
      }
    }
    return implode('&', $parts);
  }

  public static function verifySignature(array $payload, string $publicKeyPath)
  {
    try {
      $canonicalString = self::createCanonicalString($payload);
      $signature = base64_decode($payload['signature'] ?? '');

      $publicKey = openssl_pkey_get_public(file_get_contents($publicKeyPath));
      if (!$publicKey) {
        throw new \Exception('Failed to load public key');
      }

      $result = openssl_verify(
        $canonicalString,
        $signature,
        $publicKey,
        OPENSSL_ALGO_SHA256
      );
      openssl_free_key($publicKey);

      return $result === 1;
    } catch (\Exception $e) {
      \Log::error('Signature verification failed: ' . $e->getMessage());
      return false;
    }
  }
}

// Usage in your webhook controller
Route::post('/webhook', function (Request $request) {
  $publicKeyPath = storage_path('app/lakipay-public.pem');

  if (!LakiPayWebhookVerifier::verifySignature($request->all(), $publicKeyPath)) {
    return response()->json(['error' => 'Invalid signature'], 401);
  }

  return response()->json(['success' => true]);
});

Best Practices

Store your public key securely

Keep the PEM public key in environment variables or a secure vault — never commit it to client-side code.

Always verify before processing

Verify the RSA signature on every webhook before updating orders, wallets, or ledgers.

Reject invalid signatures

Return HTTP 401 (or similar) and do not process webhooks that fail verification.

Idempotency

Use lakipayTxnId (and optionally referenceId) to ignore duplicate deliveries safely.

Monitor verification failures

Treat repeated signature failures as a potential security issue and investigate promptly.

Use HTTPS and acknowledge quickly

Callback URLs must be HTTPS. Return HTTP 200 promptly; process heavy work asynchronously.

Troubleshooting

If signature verification fails, ensure:

  • The public key was correctly downloaded from Settings → Security → Security Keys
  • You are not modifying the webhook payload before verification
  • The signature field is excluded when creating the canonical string
  • Fields are sorted alphabetically by key name
  • You are using SHA-256 hashing with RSA PKCS#1 v1.5 padding