Get notified when an invoice is paid or a proposal is signed
Register a webhook, verify signatures, and handle invoice.paid and proposal.signed events end-to-end.
This guide builds a complete webhook handler that receives invoice.paid and proposal.signed events, verifies every delivery's signature, and processes each event type.
APIs used: Create webhook · Delete webhook Related concepts: Verifying signatures · Retries & idempotency
Prerequisites
KWILL_API_KEYandKWILL_WEBHOOK_SECRETin your environment- A publicly reachable HTTPS endpoint (for local dev, see Testing locally)
Step 1 — Register the webhook
Subscribe to the two events you care about:
curl -X POST https://usekwill.com/api/v1/webhooks \
-H "Authorization: Bearer $KWILL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://myapp.com/webhooks/kwill",
"event_types": ["invoice.paid", "proposal.signed"]
}'The secret field is shown only once. Save it immediately as an environment variable. If you lose it, delete the endpoint and create a new one.
Step 2 — Build the handler
The handler must:
- Read the raw body before parsing (required for signature verification)
- Verify the signature
- Acknowledge with
2xximmediately - Deduplicate on
X-Kwill-Event-Id - Handle each event type
// webhook-handler.mjs
import express from 'express';
import crypto from 'node:crypto';
const app = express();
function verifySignature(rawBody, sigHeader, secret) {
const parts = Object.fromEntries(sigHeader.split(',').map((p) => p.split('=', 2)));
const timestamp = parseInt(parts.t, 10);
const signature = parts.v1;
if (!timestamp || !signature) throw new Error('Invalid signature header.');
if (Math.abs(Date.now() / 1000 - timestamp) > 300) throw new Error('Timestamp too old.');
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const expectedBuf = Buffer.from(expected, 'hex');
const receivedBuf = Buffer.from(signature, 'hex');
if (expectedBuf.length !== receivedBuf.length || !crypto.timingSafeEqual(expectedBuf, receivedBuf)) {
throw new Error('Signature mismatch.');
}
}
// Idempotency store — use Redis or a DB in production
const processedEvents = new Set();
app.post(
'/webhooks/kwill',
express.raw({ type: 'application/json' }),
async (req, res) => {
// 1. Verify signature
try {
verifySignature(
req.body.toString('utf8'),
req.headers['x-kwill-signature'] ?? '',
process.env.KWILL_WEBHOOK_SECRET
);
} catch (err) {
console.error('Signature verification failed:', err.message);
return res.status(400).send(err.message);
}
// 2. Acknowledge immediately
res.sendStatus(200);
// 3. Deduplicate
const eventId = req.headers['x-kwill-event-id'];
if (processedEvents.has(eventId)) return;
processedEvents.add(eventId);
// 4. Handle
const event = JSON.parse(req.body);
if (event.type === 'invoice.paid') {
const invoice = event.data.object;
console.log(`Invoice ${invoice.invoice_number} paid — $${invoice.total} ${invoice.currency}`);
// e.g. update your accounting system, send a receipt, etc.
}
if (event.type === 'proposal.signed') {
const proposal = event.data.object;
console.log(`Proposal "${proposal.title}" accepted by client ${proposal.client_id}`);
// e.g. kick off onboarding workflow
}
}
);
app.listen(3000, () => console.log('Listening on :3000'));Step 3 — Test locally
See the Testing locally guide to use ngrok to forward events to your local server before deploying.
Step 4 — List all registered webhooks
curl https://usekwill.com/api/v1/webhooks \
-H "Authorization: Bearer $KWILL_API_KEY"Step 5 — Delete a webhook
curl -X DELETE https://usekwill.com/api/v1/webhooks/{id} \
-H "Authorization: Bearer $KWILL_API_KEY"