Webhooks: event notifications, inbound triggers, Zapier, and Shopify
Get a signed POST on your server when someone subscribes, unsubscribes, signs up, or buys. Event types, payloads, verifying signatures, retries. Plus inbound triggers from Zapier and Shopify.
Webhooks go two ways. Outbound: InfluencersKit posts an event to your server when something happens on your account. Inbound: Zapier, Shopify, or your app posts to InfluencersKit to start a trigger.
Before you start (outbound)
- Your workspace is on Pro or Custom, and you're a workspace admin.
- Your endpoint is reachable on the public internet over HTTPS, on port 443 or 8443, with a valid certificate.
- It answers with any 2xx status within 15 seconds. Do slow work after you answer.
Add an endpoint
- 1Go to Settings → API keys & webhooks.
- 2Under Webhooks, enter the Endpoint URL, such as
https://example.com/ik-webhooks. - 3Tick the events you want.
- 4Select Add endpoint.
- 5Copy the signing secret from the dialog, starting
whsec_, and store it with your server's other secrets. It's shown once. - 6Select Send test event, then Show delivery log, and check the
pingdelivery succeeded.
- URLs must use
https://, have no user name or password, and point to a public address. Addresses such aslocalhost,10.x.x.xor192.168.x.xare refused, both when you save and before every send. - Redirects aren't followed. Use the final URL.
- You can add up to 10 endpoints.
Event types
| Event | Sent when |
|---|---|
subscriber.created | A new address is added to your list: in the dashboard, by the API, or through a form, landing page or creator page. CSV imports don't send it. |
subscriber.unsubscribed | Someone unsubscribes from everything (unsubscribe page or one-click), marks your email as spam, or is set to unsubscribed in the dashboard or by the API. data.reason is unsubscribe_page, one_click, complaint, edited or api. |
form.submitted | Someone submits one of your live forms, new to your list or not. Sent at once, before any email confirmation: subscriber_status is pending_confirmation until they confirm. Not sent for an address you deleted after it opted out. |
landing_page.submitted | Someone signs up on one of your live landing pages, new to your list or not. Same rules as form.submitted. |
purchase.completed | A Stripe payment is recorded for a digital good, course, coaching booking, or membership renewal. |
purchase.refunded | A sale is fully refunded in Stripe. |
ping | You selected Send test event. You can't subscribe to it. |
automation.rule_fired | A behavior rule with a Send a webhook action fired. It goes only to the endpoint the rule names. |
integration.forwarded | An integration trigger with a Forward to webhook action ran. It goes only to the endpoint the trigger names. |
What we send
A POST with a JSON body. Every event has the same envelope: id (unique per event, the same on every retry), type, created (Unix seconds), and data.
{
"id": "evt_6c1f0e2a9b7d4c3e8f1a2b3c",
"type": "subscriber.created",
"created": 1762164000,
"data": {
"id": "6727f0c2a1b2c3d4e5f60718",
"email": "reader@example.com",
"first_name": "Ada",
"last_name": null,
"status": "active",
"tags": ["newsletter"],
"source": "form:your-handle/your-form",
"created_at": "2026-11-03T10:00:00Z"
}
}Other events' data:
| Event | data fields |
|---|---|
subscriber.unsubscribed | The subscriber fields above, plus reason. |
form.submitted | form_id, form_slug, email, first_name, last_name, subscriber_id, subscriber_status |
landing_page.submitted | landing_page_id, landing_page_slug, and the same subscriber fields as forms |
purchase.completed, purchase.refunded | id, product_type (digital_good, course_enrollment, coaching_booking, course_renewal), product_id, amount (in currency units, such as 19.99), currency, customer_email, payment_provider, created_at |
ping | message, endpoint_id |
Headers
| Header | Value |
|---|---|
IK-Signature | t=1762164000,v1=5257a869…: the timestamp and the signature. See below. |
IK-Webhook-Id | The event id. |
IK-Event-Type | The event type. |
IK-Webhook-Timestamp | Unix seconds when this attempt was sent. |
IK-Webhook-Attempt | 1 for the first try, 2 for the first retry, and so on. |
User-Agent | InfluencersKit-Webhooks/1.0 |
Verify the signature
Check every request before you trust it. The signature is an HMAC-SHA256, in hex, of the timestamp, a full stop, and the raw request body, keyed with your whole secret including whsec_. Use the body exactly as received: parsing and re-serialising the JSON changes it.
Refuse a request whose timestamp is more than five minutes from your clock. Each attempt is signed with a fresh timestamp, so a captured request can't be replayed later.
After you roll the secret, the header carries two v1 values for 24 hours, one per secret. Accept the request if any one matches.
// Node.js with Express
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.IK_WEBHOOK_SECRET; // whsec_...
function verifyIkSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false;
let t = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split("=");
if (key === "t") t = Number(value);
if (key === "v1") signatures.push(value);
}
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
return signatures.some(
(s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))
);
}
const app = express();
app.post("/ik-webhooks", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyIkSignature(req.body, req.get("IK-Signature"), SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// Skip event.id if you've handled it already, then queue your work.
res.sendStatus(200);
});
app.listen(3000);The same check in Python:
import hashlib
import hmac
import time
def verify_ik_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
if not header:
return False
t, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
t = int(value)
elif key == "v1":
signatures.append(value)
if t is None or abs(time.time() - t) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)Retries
A delivery succeeds when your endpoint answers 2xx within 15 seconds. Anything else is a failure: another status, a redirect, a timeout, a TLS error, or a URL that now points to a private address.
A failed delivery is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours, each a little later at random. That's 8 attempts over about 42 hours. After the last one the delivery is marked failed.
| Delivery status | Meaning |
|---|---|
| pending | Waiting for its first try or its next retry. |
| succeeded | Your endpoint answered 2xx. |
| failed | Every attempt failed. Select Resend to try again. |
| cancelled | Not sent, because the endpoint was turned off or deleted first. |
Delivery log
- Select Show delivery log on an endpoint to see its last 50 deliveries: status, attempts, the last HTTP status and error, the start of your response, and the exact body sent.
- Resend queues a finished delivery again, with the same event
idand body. - Deliveries are kept for 30 days.
When an endpoint keeps failing
After 10 failed attempts in a row, spread over at least 24 hours, the endpoint is turned off. Its waiting deliveries are cancelled, the reason is shown on the endpoint, and your workspace admins get an email. Any success resets the count.
To start again, fix the endpoint, switch it back On, select Send test event, and resend the deliveries you need from the log.
Roll the signing secret
- 1Select Roll secret on the endpoint and confirm.
- 2Copy the new secret from the dialog.
- 3Put the new secret in your server within 24 hours. Until then, requests are signed with both.
Inbound triggers
Integration Triggers let other apps start actions in InfluencersKit. Adding an integration needs a plan with Integration Triggers. The people you act on must already be subscribers, matched by email address.
| Source | URL | How it's verified |
|---|---|---|
| Zapier | https://influencerskit.com/api/integrations/inbound/zapier?token=YOUR_TOKEN | Webhook token in the URL |
| Custom | https://influencerskit.com/api/integrations/inbound/custom?token=YOUR_TOKEN | Webhook token in the URL |
| Shopify | https://influencerskit.com/api/integrations/inbound/shopify | X-Shopify-Hmac-Sha256 with your signing secret |
Set up a trigger
- 1Go to Automations → Integration Triggers and open the Integrations tab.
- 2Select Add integration. Enter a Name and choose a Platform.
- 3Open the Triggers tab and select New trigger. Choose the Integration, enter a Name and set Trigger event.
- 4In Actions, enter a JSON array, such as
[{"type":"dispatch_event","eventType":"SUBSCRIBE"}]. - 5Set Status to Active and save.
- 6Open the Settings tab and copy the Zapier or Custom URL. The Webhook token there covers your whole account: keep it out of public code.
Send an inbound event
POST a JSON object. Fields are read from the top level only.
- A valid token returns 200 with the body
ok, whether or not a trigger matched. A wrong token returns 404{"message":"Invalid token","status":404}. - For Zapier and Custom, the event name is the
eventfield, thentype. Without either it'szapier.webhookorcustom.webhook. - For Shopify, the event name is the
X-Shopify-Topicheader, such asorders/create. Save the Shopify signing secret first: without it every Shopify event is refused with 401Shopify webhook secret not set. - Trigger event must equal the event name, ignoring case. Leave it blank, or use
*, to match every event.
curl -X POST "https://influencerskit.com/api/integrations/inbound/custom?token=YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"event":"order.paid","email":"reader@example.com","plan":"annual"}'Inbound actions
Success in Execution logs means the trigger ran, not that anything happened. A log shows Success when the address isn't a subscriber, and for actions that do nothing. Ignore the inbound URL shown for Stripe on the Integration Triggers page: it doesn't exist. A Stripe trigger runs only for checkout.session.completed, from your own payment webhook. Retry policy, Rate limit / min, and the notify options on that Settings tab save but have no effect.
| Template | JSON | What happens |
|---|---|---|
| Dispatch event | {"type":"dispatch_event","eventType":"SUBSCRIBE"} | Enrolls the subscriber in email into your active Welcome Series sequences. This is the only action that changes anything today. |
| Dispatch event | {"type":"dispatch_event","eventType":"CUSTOM_EVENT"} | No visible effect. This is the default template. |
| Queue email | {"type":"queue_email","templateId":"…"} | No email goes out. The job is created, then fails. |
| Add tag | {"type":"add_tag","tag":"…"} | Does nothing. No tag is added. |
| Forward webhook | {"type":"webhook_forward","url":"…"} | Does nothing. Use an outbound endpoint above instead. |
Frequently asked questions
Which plan includes webhooks?
Pro and Custom. On other plans nothing is sent, and endpoints can't be added or turned on.
Can the same event arrive twice?
Yes, occasionally, for example when your server timed out but still processed it. Use the event id to skip ones you've already handled.
Do events arrive in order?
Not guaranteed. Use the created time in each event, and read the current state from the API if order matters.
Does a CSV import send subscriber.created?
No. Imports add subscribers in bulk without events, so an import can't flood your endpoint.
Are failed inbound events retried?
No. An inbound trigger runs once when the request arrives. Only the sender's own retry rules apply. Outbound events are retried, as described above.
Ready to try it?
Open your dashboard and follow along — or create a free account.