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

bash
PUT /api/v2/lakiconnect/connected-accounts/webhook-settings
X-API-Key: lk_live_xxxxxxxxxxxxxxxxxxxx
json
{
  "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:

text
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:

javascript
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:

go
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

EventTrigger
`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

json
{
  "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:

json
{
  "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_key to deduplicate.
  • All deliveries (success and failure) are logged in LakiConnectWebhookDispatchLog for replay analysis.
  • Return HTTP 2xx to acknowledge receipt. Non-2xx responses are treated as delivery failures.
  • Failed deliveries are retried with exponential backoff.

Retrieve Webhook Settings

bash
GET /api/v2/lakiconnect/connected-accounts/webhook-settings
X-API-Key: lk_live_xxxxxxxxxxxxxxxxxxxx

Per-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.

AspectDetail
Event types`DEPOSIT`, `WITHDRAWAL`
SecurityRSA-2048 signature (SHA-256, PKCS#1 v1.5) in the JSON body
ConfigurationPer-transaction `callback_url` on payment requests
AcknowledgeReturn HTTP 200; failed deliveries are retried

Payment Callback Events

EventDescription
`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
`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

StatusDescription
`SUCCESS`Transaction completed successfully.
`FAILED`Transaction failed.
`PENDING`Transaction in progress.

Payment Callback Signature Verification

Getting your public key

  1. Log in to your LakiPay merchant dashboard.
  2. Navigate to Settings → Security → Security Keys.
  3. Download your public key (PEM format).
  4. Regenerate / rotate keys from the same location when needed.

How it works

  1. Build a canonical string from all fields except signature, sorted alphabetically as key=value pairs joined by &.
  2. Hash with SHA-256.
  3. Sign with RSA PKCS#1 v1.5.
  4. Base64-encode the signature and include it in the webhook body.

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"
}

Payment Callback Verification Examples

Node.js

javascript
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

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

php
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

PracticeDescription
Store public key securelyUse environment variables or a secure vault for the PEM key.
Always verify before processingReject webhooks with invalid signatures (HTTP 401).
Use HTTPSCallback URLs must use HTTPS.
IdempotencyDeduplicate with `lakipayTxnId` (and optionally `referenceId`).
Monitor failuresTreat repeated verification failures as a security signal.
Acknowledge quicklyReturn HTTP 200 promptly; process heavy work asynchronously.