Webhooks
Receive real-time contract status notifications via signed webhooks.
Kenal Stamps delivers webhook events to your configured endpoint when contract statuses change. Webhooks are signed with your webhookSecret so you can verify their authenticity.
Setup
Configure your webhookUrl when creating or editing your integration instance in the dashboard at /dashboard/admin/integrations.
Webhooks are only sent for contracts submitted via the Partner Integration API (i.e., contracts with a partnerId).
Event Types
| Event | Trigger |
|---|---|
ContractConfirmed | Contract confirmed and wallet charged |
ContractProcessing | Contract picked up for LHDN submission |
ContractCompleted | Stamping complete, certificate available |
ContractFailed | Processing failed |
Payload
{
"eventType": "ContractCompleted",
"contractId": "a1b2c3d4-...",
"externalReferenceId": "LOAN-001",
"organizationId": "...",
"contractType": "General",
"status": "Completed",
"occurredAt": "2026-03-15T14:30:00.000Z"
}Webhook Signing
Each webhook delivery includes these headers:
| Header | Description |
|---|---|
x-service-id | Your integration UUID |
x-event-type | The event type (e.g. ContractCompleted) |
x-timestamp | ISO-8601 timestamp |
x-signature | HMAC-SHA256 hex signature |
Verification Algorithm
import crypto from "crypto";
function verifyWebhook(
webhookSecret: string,
timestamp: string,
eventType: string,
rawBody: string,
receivedSignature: string
): boolean {
const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
const canonical = `${timestamp}\n${eventType}\n${bodyHash}`;
const expected = crypto
.createHmac("sha256", webhookSecret)
.update(canonical)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(receivedSignature, "hex")
);
}Always use crypto.timingSafeEqual() for signature comparison to prevent timing attacks.
Delivery Semantics
- At-least-once delivery: your endpoint may receive the same event more than once. Use
contractId+eventTypefor deduplication. - Retry policy: failed deliveries (non-2xx response) are retried with exponential backoff — base 30 seconds, doubling per attempt, capped at 1 hour, up to 10 attempts.
- Timeout: your endpoint must respond within 10 seconds.
Handling Webhooks
Your endpoint should:
- Verify the signature before processing
- Return 200 immediately (process asynchronously if needed)
- Be idempotent — handle duplicate deliveries gracefully
import express from "express";
app.post("/webhooks/kenal-stamps", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-signature"] as string;
const timestamp = req.headers["x-timestamp"] as string;
const eventType = req.headers["x-event-type"] as string;
if (!verifyWebhook(WEBHOOK_SECRET, timestamp, eventType, req.body.toString(), signature)) {
return res.status(401).send("Invalid signature");
}
const payload = JSON.parse(req.body.toString());
// Process the event asynchronously
processWebhookEvent(payload).catch(console.error);
// Return 200 immediately
res.status(200).send("OK");
});Monitoring
View webhook delivery status in the admin dashboard at /admin/webhooks. You can:
- See all pending, delivered, and failed webhook events
- Retry failed deliveries manually
- Filter by status and search by contract ID