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.
DEPOSIT, WITHDRAWAL
RSA-2048 signature verification (SHA-256, PKCS#1 v1.5)
Webhook Events
Event Types
Payment / deposit transactions
Withdrawal transactions
Webhook Payload
{
"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..."
}| Field | Description |
|---|---|
| event | DEPOSIT or WITHDRAWAL |
| referenceId | Your merchant reference for the transaction |
| lakipayTxnId | LakiPay transaction ID (use for idempotency) |
| status | SUCCESS, FAILED, or PENDING |
| amount | Transaction amount as a string |
| callbackUrl | The callback URL that received this webhook |
| message | Human-readable status message |
| providerTxId | Upstream provider transaction ID |
| timestamp | Event timestamp (ISO 8601) |
| merchantId | Your LakiPay merchant ID |
| userId | Associated user ID |
| signature | Base64-encoded RSA-SHA256 signature of the payload |
Status Values
Transaction completed successfully
Transaction failed
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)
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
- 1Log in to your LakiPay merchant dashboard
- 2Navigate to Settings → Security → Security Keys
- 3Download your public key (PEM format)
- 4Rotate keys from the same location when needed by regenerating a new public key
How It Works
- 1LakiPay creates a canonical string from the webhook payload by extracting all fields except
signature, sorting fields alphabetically by key name, and formatting askey=valuepairs joined by& - 2LakiPay hashes this string using SHA-256
- 3LakiPay signs the hash using RSA with PKCS#1 v1.5 padding
- 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
{
"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
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
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
<?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
signaturefield 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