Guide to Kit Webhooks: Features and Best Practices
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
| Feature | Details |
|---|---|
| Configuration | POST /v4/webhook_ (API key or OAuth) or app.kit.com/webhooks |
| Subscription model | Each endpoint lists the event types it wants |
| Delivery | POST of {delivery_id, events}, 1 to 100 events of one type |
| Signature header | X-Kit-Signature: t=<unix seconds>,v1=<hex> |
| Scheme | HMAC-SHA256, hex digest |
| Signed content | {t}.{raw_body} |
| Secret | whsec_..., used whole as UTF-8 bytes. Shown only on create and rotate |
| Secret rotation | Live, with an overlap window during which both secrets sign |
| Replay window | Kit suggests 5 minutes |
| Response window | 5 seconds to connect, 10 seconds for a 2xx |
| Retries | 8 attempts over about 41 hours |
| Delivery history | 30 days, through the API |
| Other headers | X-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_, subscriber.link_clicked, subscriber.email_opened, landing_page.created and landing_page.deleted, are rejected with a 422 until they ship.
| Area | Events |
|---|---|
| Subscriber lifecycle | subscriber.created, subscriber.activated, subscriber.unsubscribed, subscriber.bounced, subscriber.complained |
| Forms and sequences | subscriber.subscribed_, subscriber.added_, subscriber.sequence_ |
| Tags and fields | subscriber.tag_added, subscriber.tag_removed, subscriber.custom_ |
| Tags and custom fields | tag.created, tag.deleted, custom_field.created, custom_field.deleted |
| Sequences | sequence.created, sequence.deleted, sequence.published, sequence.disabled |
| Broadcasts and posts | broadcast.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_ fires once per changed field. post.published carries only a summary, so fetch the full post from the API.
Setting up Kit webhooks
- 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"]
}'
- Copy
webhook_endpoint.secretfrom the response and store it server-side, for example asKIT_WEBHOOK_SECRET. Kit says "this is the only time it's returned in plaintext". - To change subscriptions later,
PATCH /v4/webhook_with a newendpoints/{id} eventsarray, which replaces the current one. - Trigger an event, for example by adding a subscriber, and check
GET /v4/webhook_if nothing arrives.endpoints/{id}/deliveries
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-Signaturewith yourwhsec_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_ opens an overlap window, until previous_, 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
v1in 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
idand skip any you have seen. Kit says to deduplicate onid, notdelivery_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
2xxas soon as the batch is verified and stored, and process it afterwards. - Return
429or503withRetry-Afterto slow Kit down, and410for 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.