Webhooks & events
Configure outbound webhooks, verify signatures, and handle event types.
Two Webhook Types
LakiConnect distinguishes between two separate webhook flows:
LakiConnect Outbound Webhooks — delivered to the master's configured webhook_url. Covers connected account KYC status changes and settlement events.
Per-Transaction Payment Callbacks — delivered to the callback_url specified on each individual transaction. Uses the standard LakiPay merchant webhook pipeline.
These are independent systems. Configuring a LakiConnect webhook URL does not affect transaction-level callbacks, and vice versa.
Configure the Outbound Webhook
PUT /api/v2/lakiconnect/connected-accounts/webhook-settings
X-API-Key: lk_live_xxxxxxxxxxxxxxxxxxxx{
"webhook_url": "https://yourplatform.com/webhooks/lakiconnect",
"webhook_secret": "whsec_your_secret_here",
"webhook_enabled": true
}Webhook Signature Verification
When a webhook_secret is configured, every outbound request includes:
X-LakiConnect-Signature: sha256=<hex_digest>The signature is computed as HMAC-SHA256 over the raw JSON body bytes using the webhook secret.
Node.js verification:
const crypto = require('crypto');
function verifyLakiConnectSignature(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
// Express example
app.post('/webhooks/lakiconnect', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-lakiconnect-signature'];
const isValid = verifyLakiConnectSignature(req.body, sig, process.env.WEBHOOK_SECRET);
if (!isValid) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body);
// handle event...
res.status(200).send('OK');
});Go verification:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
)
func verifySignature(body []byte, signatureHeader, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signatureHeader))
}Event Types
| Event | Trigger |
|---|---|
| `connected_account.approved` | Connected merchant KYC approved. |
| `connected_account.declined` | Connected merchant KYC declined. |
| `settlement.approved` | Settlement approved (see note below). |
| `settlement.declined` | Settlement declined (see note below). |
Webhook Envelope Schema
{
"event": "connected_account.approved",
"idempotency_key": "evt_abc123def456",
"occurred_at": "2025-10-01T09:10:00Z",
"data": {
"object": "connected_account",
"id": "cm_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"master_merchant_id": "mm_9f8e7d6c-b5a4-3210-fedc-ba9876543210",
"kyc_status": "approved"
}
}Settlement event payload:
{
"event": "settlement.approved",
"idempotency_key": "evt_def456ghi789",
"occurred_at": "2025-10-02T14:00:00Z",
"data": {
"object": "settlement",
"id": "wd_321fedcba098",
"connected_account_id": "cm_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"amount": 47500,
"status": "approved"
}
}Delivery Behavior
- Webhooks are delivered at least once. Your endpoint must be idempotent — use
idempotency_keyto deduplicate. - All deliveries (success and failure) are logged in
LakiConnectWebhookDispatchLogfor replay analysis. - Return
HTTP 2xxto acknowledge receipt. Non-2xx responses are treated as delivery failures. - Failed deliveries are retried with exponential backoff.
Retrieve Webhook Settings
GET /api/v2/lakiconnect/connected-accounts/webhook-settings
X-API-Key: lk_live_xxxxxxxxxxxxxxxxxxxxPer-Transaction Payment Callbacks
When you create a direct payment, hosted checkout, or withdrawal for a connected merchant, LakiPay sends real-time notifications to the callback_url you supplied on that transaction. This uses the standard LakiPay merchant webhook pipeline — not the LakiConnect outbound webhook configured above.
Method: POST with Content-Type: application/json.
| Aspect | Detail |
|---|---|
| Event types | `DEPOSIT`, `WITHDRAWAL` |
| Security | RSA-2048 signature (SHA-256, PKCS#1 v1.5) in the JSON body |
| Configuration | Per-transaction `callback_url` on payment requests |
| Acknowledge | Return HTTP 200; failed deliveries are retried |
Payment Callback Events
| Event | Description |
|---|---|
| `DEPOSIT` | Payment / deposit transactions. |
| `WITHDRAWAL` | 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` | LakiPay merchant ID |
| `userId` | Associated user ID |
| `signature` | Base64-encoded RSA-SHA256 signature of the payload |
Status values
| Status | Description |
|---|---|
| `SUCCESS` | Transaction completed successfully. |
| `FAILED` | Transaction failed. |
| `PENDING` | Transaction in progress. |
Payment Callback Signature Verification
Getting your public key
- Log in to your LakiPay merchant dashboard.
- Navigate to Settings → Security → Security Keys.
- Download your public key (PEM format).
- Regenerate / rotate keys from the same location when needed.
How it works
- Build a canonical string from all fields except
signature, sorted alphabetically askey=valuepairs joined by&. - Hash with SHA-256.
- Sign with RSA PKCS#1 v1.5.
- Base64-encode the signature and include it in the webhook body.
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"
}Payment Callback 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 signatureBuffer = Buffer.from(payload.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;
}
}
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' });
}
res.json({ success: true });
});Java
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);
byte[] signatureBytes = Base64.getDecoder().decode((String) payload.get("signature"));
String publicKeyContent = publicKeyPEM
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
PublicKey publicKey = KeyFactory.getInstance("RSA")
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyContent)));
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initVerify(publicKey);
sig.update(canonicalString.getBytes(StandardCharsets.UTF_8));
return sig.verify(signatureBytes);
} catch (Exception e) {
return false;
}
}Laravel
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)
{
$canonicalString = self::createCanonicalString($payload);
$signature = base64_decode($payload['signature'] ?? '');
$publicKey = openssl_pkey_get_public(file_get_contents($publicKeyPath));
$result = openssl_verify($canonicalString, $signature, $publicKey, OPENSSL_ALGO_SHA256);
return $result === 1;
}Payment Callback Best Practices
| Practice | Description |
|---|---|
| Store public key securely | Use environment variables or a secure vault for the PEM key. |
| Always verify before processing | Reject webhooks with invalid signatures (HTTP 401). |
| Use HTTPS | Callback URLs must use HTTPS. |
| Idempotency | Deduplicate with `lakipayTxnId` (and optionally `referenceId`). |
| Monitor failures | Treat repeated verification failures as a security signal. |
| Acknowledge quickly | Return HTTP 200 promptly; process heavy work asynchronously. |