Webhooks
| Webhook | Events | Status | Activity | Source | Created |
|---|
The webhook object
Each webhook you create is represented by the following object. The signing_secret is only returned once — at creation time.
| Field | Type | Description |
|---|---|---|
id | integer | Unique identifier for the webhook. |
name | string | Auto-generated human-readable name (e.g. swift-cedar). |
account_id | integer | The Kit account this webhook belongs to. |
events | string[] | One or more event names this webhook listens to. |
target_url | string | The HTTPS endpoint Kit delivers events to. |
payload_style | enum | snapshot — full object embedded. thin — event metadata only. |
status | enum | active or disabled. |
signing_secret | string | HMAC-SHA256 key used to sign deliveries. Shown once on creation. |
created_at | datetime | ISO 8601 timestamp of creation. |
Delivery payload
Kit sends a POST request to your target_url with a JSON body. The shape depends on your webhook's payload_style.
Payload styles
Snapshot
The full current state of the related object is embedded. Easiest to work with — no follow-up API call required.
Thin
Only event metadata and a resource ID are sent. Fetch the current state from the API at processing time. Useful when you need the most up-to-date data rather than the state at event time.
Responding to deliveries
Kit considers a delivery successful when your endpoint returns a 2xx status within 30 seconds. Return the status immediately and process the event asynchronously — never perform slow operations (database writes, API calls, emails) before responding.
| Status | How Kit treats it |
|---|---|
2xx | Success — delivery marked delivered, no retry. |
3xx | Redirect not followed — treated as a failure. Update your target_url to the final destination. |
4xx | Client error — delivery fails immediately, not retried. Fix your endpoint before re-enabling. |
5xx | Server error or timeout — delivery retried with exponential backoff. |
Retry schedule
Kit retries failed deliveries (5xx or timeout) with exponential backoff for up to 72 hours. A webhook that is disabled or deleted stops receiving retries immediately.
| Attempt | Delay after previous |
|---|---|
| 1st retry | 5 minutes |
| 2nd retry | 30 minutes |
| 3rd retry | 2 hours |
| 4th retry | 5 hours |
| 5th+ retry | Up to 24 hours, until 72 h window expires |
Handling duplicates
Kit may deliver the same event more than once (e.g. if your endpoint returned 200 but the connection dropped before Kit received it). Make your handlers idempotent by checking the webhook_id and event combination — or a unique ID on the payload object — before taking action.
Event ordering
Kit does not guarantee delivery in the order events occurred. A subscriber.tag_removed may arrive before the corresponding subscriber.tag_added if there is a delivery delay. Design handlers to be independent and fetch current state from the API when ordering matters.
Event types
The events array on a webhook may contain any combination of the following. Multi-event webhooks fire to a single target_url — use the event field in the payload to route internally.
| Event | Triggered when | Objects in data |
|---|---|---|
| Subscriber | ||
subscriber.activated | A subscriber confirms their subscription. | subscriber |
subscriber.unsubscribed | A subscriber opts out. | subscriber |
subscriber.bounced | An email hard bounces. | subscriber |
subscriber.complained | A subscriber marks an email as spam. | subscriber |
subscriber.subscribed_to_form | A subscriber submits a Kit form. | subscriber + form |
subscriber.added_to_sequence | A subscriber is added to a sequence. | subscriber + sequence |
subscriber.sequence_completed | A subscriber completes a sequence. | subscriber + sequence |
subscriber.link_clicked | A subscriber clicks a link in an email. | subscriber + email |
subscriber.tag_added | A tag is applied to a subscriber. | subscriber + tag |
subscriber.tag_removed | A tag is removed from a subscriber. | subscriber + tag |
subscriber.product_purchased | A subscriber makes a purchase. | subscriber + purchase |
subscriber.custom_field_value_updated | A custom field value changes. | subscriber + custom_field |
| Custom Fields | ||
custom_field.created | A custom field is created. | custom_field |
custom_field.deleted | A custom field is deleted. | custom_field |
| Tags — coming soon | ||
tag.created | A tag is created. | tag |
tag.deleted | A tag is deleted. | tag |
| Broadcasts — coming soon | ||
broadcast.sent | A broadcast send completes. | broadcast |
broadcast.created | A broadcast is created. | broadcast |
broadcast.deleted | A broadcast is deleted. | broadcast |
| Sequences — coming soon | ||
sequence.created | A sequence is created. | sequence |
sequence.deleted | A sequence is deleted. | sequence |
sequence.published | A sequence is published. | sequence |
sequence.disabled | A sequence is disabled. | sequence |
sequence.email_sent | A sequence email is sent to a subscriber. | sequence + sequence_email |
| Landing Pages — coming soon | ||
landing_page.created | A landing page is created. | landing_page |
landing_page.deleted | A landing page is deleted. | landing_page |
| Automations — coming soon | ||
visual_automation.started | A subscriber enters a visual automation. | visual_automation |
visual_automation.completed | A subscriber completes a visual automation. | visual_automation |
rule.triggered | A rule is triggered. | rule |
| Segments — coming soon | ||
segment.created | A segment is created. | segment |
segment.deleted | A segment is deleted. | segment |
| Email Templates — coming soon | ||
email_template.created | An email template is created. | email_template |
email_template.deleted | An email template is deleted. | email_template |
Verifying signatures
Every delivery includes a X-Kit-Signature header — an HMAC-SHA256 hex digest of the raw request body, signed with your webhook's signing_secret.
Steps
1. Read the raw request body as bytes — do not parse JSON first.
2. Compute HMAC-SHA256(raw_body, signing_secret).
3. Compare to X-Kit-Signature using a constant-time comparison.
4. Reject with 401 if they don't match.
Replay attack protection
Each delivery includes an X-Kit-Delivery header with a unique delivery ID and an X-Kit-Timestamp header with a Unix timestamp of when the request was sent. To guard against replay attacks, reject requests where the timestamp is more than 5 minutes old. Ensure your server clock is synchronised (NTP) so the comparison is accurate.
X-Kit-Delivery IDs for the tolerance window to detect and discard duplicates delivered within that period.
CSRF protection
Most web frameworks (Rails, Django, Laravel) apply CSRF token validation to all incoming POST routes. Kit's webhook requests don't carry a CSRF token and will be rejected with 422 if CSRF is active. Exempt your webhook route explicitly:
| Framework | How to exempt |
|---|---|
| Rails | protect_from_forgery except: :receive in the controller, or skip_before_action :verify_authenticity_token. |
| Django | Apply the @csrf_exempt decorator to your view function. |
| Laravel | Add the route to the $except array in VerifyCsrfToken middleware. |
Rolling the signing secret
Roll the signing secret whenever it may be compromised or as part of a regular rotation policy. Rolling generates a new secret immediately and starts a 24-hour overlap window during which Kit accepts signatures from both the old and new secrets. Use this window to update your environment variable and redeploy without dropping any deliveries.
/v4/webhooks/{id}/roll_secretNo request body required. Returns the updated webhook object with the new signing_secret. Store it immediately — it will not be shown again.
401.
List webhooks
/v4/webhooksReturns a paginated list of all webhooks for the authenticated account.
Query parameters
| Parameter | Type | Description |
|---|---|---|
after | string | Cursor for forward pagination. |
before | string | Cursor for backward pagination. |
per_page | integer | Results per page. Default 500, max 1000. |
Create a webhook
/v4/webhooksCreates a new webhook. The signing_secret is only returned in this response — store it securely immediately.
Body parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
target_url | string | required | HTTPS endpoint to deliver events to. |
events | string[] | required | One or more event names to subscribe to. |
payload_style | enum | snapshot (default) or thin. |
Edit a webhook
/v4/webhooks/{id}Updates an existing webhook. Only the fields you include in the request body are changed — omitted fields retain their current values.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | The ID of the webhook to update. |
Body parameters
| Parameter | Type | Description |
|---|---|---|
target_url | string | New HTTPS endpoint to deliver events to. |
events | string[] | Replaces the full events array. Must include at least one event. |
payload_style | enum | snapshot or thin. |
status | enum | active or disabled. |
signing_secret.
Delete a webhook
/v4/webhooks/{id}Permanently deletes the webhook. In-flight deliveries are not cancelled. Returns 204 No Content on success.
Path parameters
| Parameter | Type | Description |
|---|---|---|
id | integer | The ID of the webhook to delete. |
Quickstart
Set up a webhook endpoint, register it with Kit, and receive your first live event in under 10 minutes.
POST requests and returns 200 immediately. Keep the handler fast — enqueue any slow processing asynchronously.X-Kit-Signature header. Verify it before processing the payload to prevent spoofed requests. See Verifying signatures.signing_secret — it's only shown once.event field to route payloads to the right handler. The payload shape depends on your payload_style — see Delivery payload.Testing
Three approaches for testing your webhook integration, from quick smoke tests to full end-to-end validation.
Send a test delivery
From the Kit dashboard, open any webhook, click ⋯ → Send test. Kit delivers a sample payload for each subscribed event immediately, without needing a real subscriber action. This is the fastest way to check your endpoint is reachable and responding correctly.
Local development with ngrok
Kit requires an HTTPS endpoint. During local development, use ngrok or Cloudflare Tunnel to expose your local server.
Start your server on e.g. port 3000, then run ngrok http 3000. Copy the https://... forwarding URL and use it as your target_url when creating or editing the webhook.
Trigger real events
Some scenarios are best tested with real Kit data:
| Event | How to trigger |
|---|---|
subscriber.activated | Submit a Kit form with a test email address. |
subscriber.tag_added | Manually add a tag to a subscriber in the dashboard. |
subscriber.unsubscribed | Click the unsubscribe link in a test broadcast. |
subscriber.product_purchased | Use the Kit API to record a test purchase on a subscriber. |
subscriber.subscribed_to_form | Submit a Kit form — fires in addition to subscriber.activated. |
subscriber.added_to_sequence | Add a subscriber to a sequence manually in the dashboard. |
Inspecting deliveries
The webhook detail panel in Kit shows the last 8 delivery attempts with status code and response time. For detailed request/response inspection during development, try webhook.site as a temporary target URL — it captures and displays every incoming request.
Event objects
The structure of the data field in each payload. For snapshot payloads the full object is embedded; for thin payloads only the id is included.
subscriber
All subscriber events share the same object shape under data.subscriber.
| Field | Type | Description |
|---|---|---|
id | integer | Unique subscriber ID. |
first_name | string | First name, if provided. |
email_address | string | Subscriber's email address. |
state | enum | active, inactive, or bounced. |
created_at | datetime | When the subscriber was created. |
fields | object | Map of custom field slugs to values. |
tags | Tag[] | Current tags applied to the subscriber. |
tag
Present as data.tag on subscriber.tag_added and subscriber.tag_removed events, identifying the tag that was applied or removed.
| Field | Type | Description |
|---|---|---|
id | integer | Tag ID. |
name | string | Tag name (e.g. Purchased). |
created_at | datetime | When the tag was created. |
custom_field
Present as data.custom_field on subscriber.custom_field_value_updated events, describing the field that changed and its new value.
| Field | Type | Description |
|---|---|---|
id | integer | Field ID. |
label | string | Display label (e.g. Company). |
key | string | Slug used in the fields map (e.g. company). |
value | string | New field value after the update. |
purchase
Fired when a new purchase is recorded. The data object contains a purchase and the associated subscriber.
| Field | Type | Description |
|---|---|---|
id | integer | Unique purchase ID. |
transaction_id | string | Your system's transaction reference. |
transaction_time | datetime | When the transaction occurred. |
subtotal | number | Order subtotal in the account's currency. |
total | number | Total including tax/shipping. |
currency | string | ISO 4217 currency code (e.g. USD). |
products | Product[] | Array of purchased products (name, pid, quantity, unit price). |
{
"webhook": {
"id": 12345,
"name": "swift-cedar",
"account_id": 67890,
"events": ["subscriber.activated"],
"target_url": "https://hooks.zapier.com/hooks/catch/123/abc/",
"payload_style": "snapshot",
"status": "active",
"created_at": "2025-01-14T00:00:00Z"
}
}
const processed = new Set(); // use Redis/DB in production
app.post('/webhooks/kit', express.raw({type:'application/json'}), (req, res) => {
verifySignature(req); // throws 401 on failure
const deliveryId = req.headers['x-kit-delivery'];
if (processed.has(deliveryId)) return res.sendStatus(200);
processed.add(deliveryId);
res.sendStatus(200); // respond before processing
handleEvent(JSON.parse(req.body));
});
{
"event": "subscriber.activated",
"webhook_id": 12345,
"occurred_at": "2025-03-25T10:00:00Z",
"subscriber": {
"id": 111222,
"email_address": "alice@example.com",
"first_name": "Alice",
"state": "active",
"tags": ["new-signup"]
}
}
{
"event": "subscriber.activated",
"webhook_id": 12345,
"occurred_at": "2025-03-25T10:00:00Z",
"subscriber_id": 111222
}
{
"event": "subscriber.tag_added",
"webhook_id": 12345,
"occurred_at": "2025-03-25T10:00:00Z",
"subscriber": {
"id": 111222,
"email_address": "alice@example.com"
},
"tag": {
"id": 9876,
"name": "vip"
}
}
POST /webhooks/kit HTTP/1.1
Content-Type: application/json
X-Kit-Signature: sha256=3d9e4f...
X-Kit-Delivery: evt_01HXYZ...
X-Kit-Timestamp: 1741080000
const ts = parseInt(req.headers['x-kit-timestamp']);
const age = Math.abs(Date.now() / 1000 - ts);
if (age > 300) return res.sendStatus(400); // > 5 min old
{
"webhook": {
"id": 12345,
"signing_secret": "whsec_9f2a1c...",
"status": "active"
}
}
{
"webhooks": [
{
"id": 12345,
"name": "swift-cedar",
"events": ["subscriber.activated"],
"status": "active"
}
],
"pagination": {
"has_next_page": false,
"end_cursor": "cursor_abc"
}
}
{
"webhook": {
"id": 12345,
"name": "swift-cedar",
"events": ["subscriber.activated"],
"target_url": "https://your-endpoint.com/kit",
"payload_style": "snapshot",
"status": "active",
"signing_secret": "whsec_aBcDeFgHiJkLmN...",
"created_at": "2025-03-25T10:00:00Z"
}
}
{
"webhook": {
"id": 12345,
"name": "swift-cedar",
"events": [
"subscriber.activated",
"subscriber.unsubscribed"
],
"target_url": "https://new-endpoint.com/kit",
"payload_style": "snapshot",
"status": "active"
}
}
# Empty response body
{
"webhook": {
"id": 12345,
"name": "swift-cedar",
"target_url": "https://abc123.ngrok.io/webhooks/kit",
"events": ["subscriber.activated"],
"payload_style": "snapshot",
"status": "active",
"signing_secret": "whsec_3d9e4f..."
}
}
{
"event": "subscriber.activated",
"webhook_id": 12345,
"data": {
"subscriber": {
"id": 0,
"first_name": "Test",
"email_address": "test@example.com",
"state": "active"
}
}
}
{
"event": "subscriber.product_purchased",
"webhook_id": 12345,
"data": {
"purchase": {
"id": 9900,
"transaction_id": "txn_abc123",
"transaction_time": "2025-03-04T10:30:00Z",
"subtotal": 97.00,
"total": 97.00,
"currency": "USD",
"products": [{
"name": "Course Bundle",
"pid": "prod_42",
"quantity": 1,
"unit_price": 97.00
}]
},
"subscriber": {
"id": 111222,
"email_address": "alex@example.com"
}
}
}
curl https://api.kit.com/v4/webhooks/12345 \
-H "Authorization: Bearer YOUR_API_KEY"
# Compute HMAC-SHA256 of raw body
echo -n "$REQUEST_BODY" | \
openssl dgst -sha256 \
-hmac "$KIT_SIGNING_SECRET"
curl -X POST \
https://api.kit.com/v4/webhooks/12345/roll_secret \
-H "Authorization: Bearer YOUR_API_KEY"
curl https://api.kit.com/v4/webhooks \
-H "Authorization: Bearer YOUR_API_KEY"
curl -X POST \
https://api.kit.com/v4/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://your-endpoint.com/kit",
"events": ["subscriber.activated"],
"payload_style": "snapshot"
}'
curl -X PATCH \
https://api.kit.com/v4/webhooks/12345 \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://new-endpoint.com/kit",
"events": [
"subscriber.activated",
"subscriber.unsubscribed"
]
}'
curl -X DELETE \
https://api.kit.com/v4/webhooks/12345 \
-H "Authorization: Bearer YOUR_API_KEY"
# Start your server, then run:
ngrok http 3000
# → https://abc123.ngrok.io
curl -X POST https://api.kit.com/v4/webhooks \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://abc123.ngrok.io/webhooks/kit",
"events": ["subscriber.activated"],
"payload_style": "snapshot"
}'
ngrok http 3000
# Forwarding: https://abc123.ngrok.io → localhost:3000
open http://localhost:4040
import Kit from '@kit/api';
const kit = new Kit({ apiKey: process.env.KIT_API_KEY });
const { webhooks } = await kit.webhooks.list();
const { webhook } = await kit.webhooks.create({
target_url: 'https://your-endpoint.com/kit',
events: ['subscriber.activated'],
payload_style: 'snapshot',
});
// Store webhook.signing_secret — shown only once
await kit.webhooks.update(12345, {
events: [
'subscriber.activated',
'subscriber.unsubscribed',
],
});
await kit.webhooks.delete(12345);
import express from 'express';
import crypto from 'crypto';
const app = express();
app.post('/webhooks/kit',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.headers['x-kit-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.KIT_SIGNING_SECRET)
.update(req.body).digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(sig), Buffer.from(expected)
)) return res.sendStatus(401);
const payload = JSON.parse(req.body);
res.sendStatus(200); // respond fast
handleEvent(payload);
}
);
async function handleEvent(payload) {
switch (payload.event) {
case 'subscriber.activated':
await syncSubscriber(payload.data.subscriber); break;
case 'subscriber.tag_added':
await onTagAdded(payload.data); break;
case 'subscriber.product_purchased':
await recordPurchase(payload.data); break;
}
}
switch (payload.event) {
case 'subscriber.activated':
case 'subscriber.unsubscribed':
// payload.data.subscriber — full object
break;
case 'subscriber.tag_added':
case 'subscriber.tag_removed':
// payload.data.subscriber + payload.data.tag
break;
case 'subscriber.custom_field_value_updated':
// payload.data.subscriber + payload.data.custom_field
break;
case 'subscriber.product_purchased':
// payload.data.purchase + payload.data.subscriber
break;
}
import crypto from 'crypto';
app.post('/webhooks/kit', (req, res) => {
const sig = req.headers['x-kit-signature'];
const expected = crypto
.createHmac('sha256', process.env.KIT_SECRET)
.update(req.rawBody).digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expected), Buffer.from(sig)
)) return res.sendStatus(401);
res.sendStatus(200); // respond fast
processEvent(req.body);
});
const { webhook } = await kit.webhooks.rollSecret(12345);
// Store webhook.signing_secret — 24h overlap then old key expires
Kit::Webhook.all
webhook = Kit::Webhook.create(
target_url: 'https://your-endpoint.com/kit',
events: ['subscriber.activated'],
payload_style: 'snapshot'
)
# Store webhook.signing_secret — shown only once
Kit::Webhook.update(12345,
events: [
'subscriber.activated',
'subscriber.unsubscribed'
]
)
Kit::Webhook.delete(12345)
def receive
body = request.raw_post
sig = request.headers['X-Kit-Signature']
expected = OpenSSL::HMAC.hexdigest(
'SHA256', ENV['KIT_SECRET'], body
)
unless ActiveSupport::SecurityUtils
.secure_compare(expected, sig)
return head :unauthorized
end
head :ok
KitEventJob.perform_later(params)
end
webhook = Kit::Webhook.roll_secret(12345)
# Store webhook.signing_secret — 24h overlap then old key expires
Kit::Webhook.find(12345)
# app/controllers/kit_webhooks_controller.rb
def receive
body = request.raw_post
sig = request.headers['X-Kit-Signature']
expected = 'sha256=' + OpenSSL::HMAC.hexdigest(
'sha256', ENV['KIT_SIGNING_SECRET'], body
)
head :unauthorized and return unless
ActiveSupport::SecurityUtils.secure_compare(sig, expected)
case params[:event]
when 'subscriber.activated'
SyncSubscriberJob.perform_later(params[:data])
when 'subscriber.product_purchased'
RecordPurchaseJob.perform_later(params[:data])
end
head :ok
end
{
"event": "subscriber.tag_added",
"webhook_id": 12345,
"data": {
"tag": {
"id": 99,
"name": "Purchased",
"created_at": "2024-11-01T00:00:00Z"
},
"subscriber": {
"id": 111222,
"first_name": "Alex",
"email_address": "alex@example.com",
"state": "active",
"created_at": "2025-01-10T12:00:00Z",
"fields": { "company": "Acme" },
"tags": [{ "id": 99, "name": "Purchased" }]
}
}
}