Gareth Wilson Gareth Wilson

Guide to Kit Webhooks: Features and Best Practices

Published


Kit webhooks tell your systems what is happening in a creator's audience. When someone subscribes through a form, gets tagged, finishes a sequence, unsubscribes or bounces, or a broadcast goes out, Kit can post the event to your endpoint, so you can sync subscribers to a CRM, grant course access, update a community or trigger your own automation without polling the API.

This guide covers how Kit webhook endpoints are created and signed, the batched delivery envelope and the events it carries, how secret rotation and retries work, and the best practices for running a Kit receiver in production.

What are Kit webhooks?

Kit, formerly ConvertKit, has two webhook systems. This guide is about webhook endpoints: a URL you register with /v4/webhook_endpoints or at app.kit.com/webhooks, subscribed to any number of event types. Kit recommends them for all new integrations.

The older /v4/webhooks resource still works and runs alongside endpoints, but it sends one event per webhook, with no signature and no retries. Kit's comparison table lists failed legacy deliveries as "dropped".

Endpoint deliveries are a JSON POST carrying an array of events, and each one is signed. The X-Kit-Signature header holds a timestamp and a hex HMAC-SHA256 of the timestamp and raw body. The secret starts with whsec_, but this is not Standard Webhooks: the whole secret, prefix included, is the HMAC key.

Kit webhook features

FeatureDetails
ConfigurationPOST /v4/webhook_endpoints (API key or OAuth) or app.kit.com/webhooks
Subscription modelEach endpoint lists the event types it wants
DeliveryPOST of {delivery_id, events}, 1 to 100 events of one type
Signature headerX-Kit-Signature: t=<unix seconds>,v1=<hex>
SchemeHMAC-SHA256, hex digest
Signed content{t}.{raw_body}
Secretwhsec_..., used whole as UTF-8 bytes. Shown only on create and rotate
Secret rotationLive, with an overlap window during which both secrets sign
Replay windowKit suggests 5 minutes
Response window5 seconds to connect, 10 seconds for a 2xx
Retries8 attempts over about 41 hours
Delivery history30 days, through the API
Other headersX-Kit-Delivery, User-Agent: Kit-Webhooks/2.0

Kit blocks delivery to private, internal and loopback addresses, so the URL must be public. It publishes no source IP list.

Common events

Kit lists 23 available events and is adding more. Five planned ones, subscriber.product_purchased, subscriber.link_clicked, subscriber.email_opened, landing_page.created and landing_page.deleted, are rejected with a 422 until they ship.

AreaEvents
Subscriber lifecyclesubscriber.created, subscriber.activated, subscriber.unsubscribed, subscriber.bounced, subscriber.complained
Forms and sequencessubscriber.subscribed_to_form, subscriber.added_to_sequence, subscriber.sequence_completed
Tags and fieldssubscriber.tag_added, subscriber.tag_removed, subscriber.custom_field_value_updated
Tags and custom fieldstag.created, tag.deleted, custom_field.created, custom_field.deleted
Sequencessequence.created, sequence.deleted, sequence.published, sequence.disabled
Broadcasts and postsbroadcast.created, broadcast.sent, broadcast.deleted, post.published

Here is Kit's example delivery:

{
  "delivery_id": 123456,
  "events": [
    {
      "id": "9c2e1f3a-6b7d-4e8f-a1b2-c3d4e5f60718",
      "type": "subscriber.created",
      "created": "2026-07-29T14:32:10Z",
      "data": {
        "subscriber": {
          "id": 987654,
          "first_name": "Ada",
          "email_address": "ada@example.com",
          "state": "active",
          "created_at": "2026-07-29T14:32:10Z",
          "fields": { "company": "Kit" }
        }
      }
    }
  ]
}

delivery_id identifies the POST, and a retry reuses it. Each event's id is a UUID that "stays the same if the event is ever re-sent", which makes it the deduplication key. created is when the event happened, not when it was delivered.

A delivery never mixes event types, and most carry one event. A bulk action in Kit batches events into deliveries of up to 100, and Kit splits a batch once it would pass roughly 512 KB. Subscriber events carry the subscriber object plus the related resource, such as the form, sequence or tag. subscriber.unsubscribed, subscriber.bounced and subscriber.complained show the new status in subscriber.state. subscriber.custom_field_value_updated fires once per changed field. post.published carries only a summary, so fetch the full post from the API.

Setting up Kit webhooks

  1. Create the endpoint with your API key:
curl -X POST "https://api.kit.com/v4/webhook_endpoints" \
  -H "X-Kit-Api-Key: $KIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/kit",
    "events": ["subscriber.created", "subscriber.unsubscribed", "subscriber.tag_added"]
  }'
  1. Copy webhook_endpoint.secret from the response and store it server-side, for example as KIT_WEBHOOK_SECRET. Kit says "this is the only time it's returned in plaintext".
  2. To change subscriptions later, PATCH /v4/webhook_endpoints/{id} with a new events array, which replaces the current one.
  3. Trigger an event, for example by adding a subscriber, and check GET /v4/webhook_endpoints/{id}/deliveries if nothing arrives.

You can do the same at app.kit.com/webhooks. Endpoints created with an OAuth token are scoped to the app that created them.

For local development, the Hookdeck CLI (hookdeck listen 3000 kit --path /webhooks/kit) gives you a public HTTPS URL that forwards to your local server, with no account required.

Securing Kit webhooks

Parse X-Kit-Signature into t and every v1 value. Build the string {t}.{raw_body}, compute an HMAC-SHA256 of it keyed with the whole whsec_ secret, encode as hex, and accept the request if any v1 matches in constant time.

const crypto = require('crypto');
const express = require('express');

function verifyKitSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !secret) return false;
  const parts = signatureHeader.split(',').map((p) => p.trim());
  const timestamp = parts.find((p) => p.startsWith('t='))?.slice(2);
  const candidates = parts.filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
  if (!timestamp || !/^\d+$/.test(timestamp) || candidates.length === 0) return false;

  // Optional: Kit suggests rejecting timestamps older than 5 minutes (300 seconds).
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
    return false;
  }

  // The whole whsec_ secret is the key. Don't strip the prefix or decode it.
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex'),
    'utf8'
  );
  return candidates.some((sig) => {
    const received = Buffer.from(sig.toLowerCase(), 'utf8');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/kit', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyKitSignature(req.body, req.get('x-kit-signature'), process.env.KIT_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const { events } = JSON.parse(req.body.toString('utf8'));
  // Iterate events, skip ids you have already processed, then process asynchronously.
  res.sendStatus(200);
});

The same check in Python:

import hashlib
import hmac
import os
import time


def verify_kit_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not secret:
        return False
    parts = [p.strip() for p in signature_header.split(",")]
    timestamp = next((p[2:] for p in parts if p.startswith("t=")), None)
    candidates = [p[3:] for p in parts if p.startswith("v1=")]
    if not timestamp or not timestamp.isdigit() or not candidates:
        return False
    # Optional: Kit suggests a 300 second tolerance.
    if tolerance_seconds > 0 and abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False
    signed = f"{timestamp}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest().encode("utf-8")
    return any(hmac.compare_digest(c.lower().encode("utf-8"), expected) for c in candidates)


# In FastAPI: raw_body = await request.body()
# verify_kit_signature(raw_body, request.headers.get("x-kit-signature"),
#                      os.environ["KIT_WEBHOOK_SECRET"])

To check your verifier, use the secret whsec_kit-test-secret and this exact body:

{"delivery_id":123456,"events":[{"id":"9c2e1f3a-6b7d-4e8f-a1b2-c3d4e5f60718","type":"subscriber.created","created":"2026-07-29T14:32:10Z","data":{"subscriber":{"id":987654,"email_address":"ada@example.com"}}}]}

The header should be:

t=1753797130,v1=e9cc5f6c4cf9f58533a984e2a06a1b0af3a1bae41937c708a6e85afff7f8da75

The tolerance check is off by default in these samples because the test timestamp is old. Kit re-signs each of its own retries with a fresh t, so a 300 second window works for direct deliveries. If a proxy or queue stores the request and forwards it later, the original t travels with it, so either skip the window there or check it where the request first lands.

Make Kit webhooks production-ready. Hookdeck Event Gateway verifies the timestamped X-Kit-Signature with your whsec_ secret, accepts each delivery within Kit's 10 second window, and durably queues every batch before your service sees it.

Kit webhook limitations and pain points

The whsec_ prefix looks like Standard Webhooks

The Problem: Verification fails with a Standard Webhooks or Svix library, or after stripping whsec_ and base64-decoding the rest.

Why It Happens: Standard Webhooks secrets also start with whsec_, and their libraries treat the rest as base64 key bytes. Kit uses the whole string as the key, signs {t}.{body} rather than {id}.{t}.{body}, and sends t=...,v1=... in one header instead of webhook-id, webhook-timestamp and webhook-signature.

Workarounds:

  • Use a plain HMAC-SHA256 with the full secret string as the key.
  • Don't install a Standard Webhooks library for Kit.

How Hookdeck Can Help: Hookdeck's Kit source type uses the full whsec_ secret as the HMAC key and parses t and v1 from X-Kit-Signature, so you paste the secret exactly as Kit returned it.

Verification breaks during secret rotation

The Problem: After rotating the secret, some deliveries fail verification.

Why It Happens: POST /v4/webhook_endpoints/{id}/rotate_secret opens an overlap window, until previous_secret_expires_at, during which Kit signs with both secrets and sends two v1 values. A verifier that reads only the first v1, or only knows one secret, fails on part of the traffic.

Workarounds:

  • Check every v1 in the header, as the samples above do.
  • Deploy the new secret, then close the window early with POST /v4/webhook_endpoints/{id}/revoke_previous_secret.

How Hookdeck Can Help: Hookdeck accepts the request when any v1 in the header matches the secret configured on the source. Update the source's secret during the overlap window and deliveries keep verifying throughout.

Batches and re-sends repeat events

The Problem: A subscriber is tagged twice in your CRM, or a welcome email goes out twice.

Why It Happens: A retry re-posts the entire events array, so events that were already handled come back. Kit also says an "upstream job retry" can re-emit an event in a new delivery with a different delivery_id.

Workarounds:

  • Record each event id and skip any you have seen. Kit says to deduplicate on id, not delivery_id.
  • Process each event in a batch independently, so one failure does not force the whole batch to be retried.

How Hookdeck Can Help: Hookdeck's deduplication can drop a repeated delivery before it reaches your service. Events re-emitted in a new delivery still need your own check on id.

A slow handler causes retries

The Problem: Your handler did the work, but Kit records a failure and sends the batch again.

Why It Happens: Kit waits 5 seconds to connect and 10 seconds for a response, and counts anything slower as a failure "even if your handler eventually finished the work". Retries follow at 30 seconds, 2 minutes, 15 minutes, 1 hour, 4 hours, 12 hours and 24 hours, after which the delivery is marked permanently failed.

Workarounds:

  • Return a 2xx as soon as the batch is verified and stored, and process it afterwards.
  • Return 429 or 503 with Retry-After to slow Kit down, and 410 for an endpoint that no longer exists.
  • Use the deliveries API to find permanently failed deliveries.

How Hookdeck Can Help: Hookdeck acknowledges each delivery straight away and then delivers to your service with your own retry policy, so a slow handler no longer means a duplicate batch from Kit.

Legacy webhooks are unsigned and not retried

The Problem: Requests from an older integration arrive without X-Kit-Signature, and events go missing during outages.

Why It Happens: Webhooks created through /v4/webhooks have no signature, and Kit drops failed deliveries.

Workarounds:

  • Recreate legacy webhooks as webhook endpoints. The event names differ, so update your routing as you move.
  • Until then, put the legacy webhook on its own URL and treat its requests as unauthenticated.

How Hookdeck Can Help: Hookdeck stores every request it accepts, which covers the retries legacy webhooks lack. A legacy webhook has nothing to verify, so leave verification off on the source that receives it.

Best practices

Verify against the raw body

The signature covers the exact bytes. Use express.raw({ type: '*/*' }) in Express, await request.text() in a Next.js route handler, or await request.body() in FastAPI, and parse only after the check.

Always iterate events

Even when you expect one event, handle the array. A bulk import or tag action can send up to 100 events in one request.

Deduplicate on event id

Store each event's UUID before processing and skip repeats. See our guide to webhook idempotency.

Acknowledge fast and process asynchronously

You have 10 seconds. Verify, store the batch and return 200, then process each event from a queue. See why to process webhooks asynchronously.

Re-read state when order matters

Events within a delivery share a type, but nothing says deliveries arrive in order. When a subscriber's current state matters, such as active versus unsubscribed, read it from the V4 subscribers API before acting.

Conclusion

Kit webhook endpoints send batches of up to 100 events, signed with a hex HMAC-SHA256 of {t}.{body} keyed with the whole whsec_ secret. Check every v1 so rotations do not break you, deduplicate on each event's id, answer within 10 seconds, and move any legacy /v4/webhooks integrations to endpoints to get signing and retries.

Hookdeck Event Gateway verifies each X-Kit-Signature, accepts every batch inside Kit's timeout and holds the events until your service has processed them, so a slow deploy does not turn into duplicate tags and emails.

Get started with Hookdeck for free and handle Kit webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.