Webhooks
Get a signed HTTP request when praise arrives or a request is answered.
Add endpoints under Developers, Webhooks. Each has its own signing secret, shown once, and its own list of events.
Events
| Event | Fires when |
|---|---|
praise.submitted | New praise arrives from a form, the API, an AI app, or the browser extension |
praise.approved | A story becomes approved |
praise.rejected | A story becomes rejected |
request.opened | A customer opens their request link for the first time |
request.completed | A customer replies through their request link |
Approving something that is already approved does not fire again. webhook.test is sent only by the Send test button.
The request
POST https://your-server.example/hook
Content-Type: application/json
KikoPraise-Event: praise.approved
KikoPraise-Delivery: evt_8fK2...
KikoPraise-Signature: t=1700000000,v1=3b1c...{
"id": "evt_8fK2...",
"type": "praise.approved",
"createdAt": "2026-10-08T01:20:00.000Z",
"data": { "testimonial": { "id": "...", "authorName": "...", "content": "...", "status": "APPROVED" } }
}The id is the same on every redelivery, so you can use it to ignore duplicates. A customer's private email address is never included.
Verify the signature
The signature is HMAC-SHA256(secret, "<t>.<raw body>") as hex, where t is the timestamp in the header. Always verify it, and reject old timestamps (5 minutes is typical).
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyKikoPraise(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = parts.v1 ?? "";
return given.length === expected.length && timingSafeEqual(Buffer.from(expected), Buffer.from(given));
}Use the raw body exactly as received. Parsing and re-serialising the JSON changes the bytes and breaks the signature.
Delivery
- Answer with a
2xxwithin 8 seconds. - A failed delivery is retried twice more, after 3 and 12 seconds, then recorded as failed. You can see and redeliver any delivery.
- Redirects are not followed.
- After 15 failures in a row the endpoint is switched off, with the reason shown. Resume turns it back on.
- The last 100 deliveries per endpoint are kept.
Safety
Production endpoints must be public https:// URLs. Private network, loopback and cloud metadata addresses are refused when you add the URL and again every time we send. Up to 10 endpoints per workspace.