Kwill

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_KEY and KWILL_WEBHOOK_SECRET in 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:

  1. Read the raw body before parsing (required for signature verification)
  2. Verify the signature
  3. Acknowledge with 2xx immediately
  4. Deduplicate on X-Kwill-Event-Id
  5. 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"

On this page