Developers

Webhooks

Kickrail can POST a signed JSON event to your server whenever an affiliate, referral, commission or payout changes. Use it to sync your CRM, post to Slack, or credit referral rewards in your own app. Webhooks need Growth or Scale.

Add an endpoint#

  1. In the dashboard, open Developers and add your URL. It must be public https; private and local addresses are refused.
  2. Pick the events to receive, or "All events", which also includes event types added later.
  3. Copy the signing secret (whsec_...) into your server's environment.
  4. Click Send test. Kickrail sends a ping event at once and shows what your server answered.

You can have up to 10 endpoints per program. Each shows its last 100 deliveries with the response status and body, and any failed delivery can be retried by hand.

Events#

EventdataWhen
affiliate.createdaffiliateSomeone joined, or you added them in the dashboard or API.
affiliate.updatedaffiliateApproved, rejected or otherwise changed. Includes previous_status.
referral.createdreferralA new person was attributed to an affiliate.
referral.updatedreferralUnder last-touch attribution, the person moved to another affiliate before paying. Includes previous_affiliate_id.
referral.convertedreferralThat person made their first payment (status is now customer).
referral.deletedreferralYou deleted the referral in the dashboard. Its unpaid commissions are deleted with it, each with its own commission.deleted. Has deleted: true.
commission.createdcommissionAn affiliate earned a commission or lead bounty, or a clawback was added after a refund.
commission.updatedcommissionA refund shrank or voided it, you released it from review, or you rejected its referral.
commission.deletedcommissionYou deleted it in the dashboard, or deleted its referral. Only unpaid commissions outside a payout can be deleted. Has deleted: true.
payout.duepayout_dueAn affiliate's balance became payable: a commission finished its hold and the balance meets the campaign's minimum. Sent each time a new commission becomes payable, not again for the same one. Has affiliate_id, campaign_id, email, name, the affiliate's metadata, currency, amount_cents (the whole payable balance), commission_ids and needs_tax_form. Use it to give a reward in kind, such as a discount, then record it with POST /v1/payouts.
payout.paidpayoutA payout went out. items[].commission_ids lists the commissions it paid.
payout.failedpayoutA PayPal or Stripe payout failed in full or in part. The failed commissions are payable again.
payout.undonepayoutYou undid a manual payout.

The objects in data are the same as in the API reference.

Payload#

POST https://yourapp.com/kickrail/webhook

Content-Type: application/json
User-Agent: Kickrail-Webhooks/1
Kickrail-Event: commission.created
Kickrail-Signature: t=1792252800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "id": "evt_8sK...",
  "type": "commission.created",
  "created": "2026-10-17T15:04:05.123000+00:00",
  "data": {
    "id": "com_4Zr...",
    "object": "commission",
    "kind": "sale",
    "affiliate_id": "aff_2Lp...",
    "referral_id": "ref_9xT...",
    "amount_cents": 1500,
    "currency": "usd",
    "sale_amount_cents": 5000,
    "status": "pending",
    "held_for_review": false,
    "due_at": "2026-11-16T15:04:05Z",
    ...
  }
}

Verify the signature#

Every request carries Kickrail-Signature: t=<unix time>,v1=<hex>, where v1 is the HMAC-SHA256 of `${t}.${raw body}` keyed with your endpoint's secret. Compute it over the raw request body, before any JSON parsing, compare in constant time, and reject timestamps more than five minutes old so a captured request can't be replayed.

Node (Express)

import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/kickrail/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Kickrail-Signature") ?? "";
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", process.env.KICKRAIL_WEBHOOK_SECRET)
    .update(`${t}.${req.body}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
  const ok = fresh && v1?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
  if (!ok) return res.status(400).send("bad signature");

  const event = JSON.parse(req.body);
  if (event.type === "commission.created") {
    // ...
  }
  res.sendStatus(200);
});

Python (Flask)

import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/kickrail/webhook")
def kickrail_webhook():
    body = request.get_data(as_text=True)
    parts = dict(p.split("=", 1) for p in request.headers.get("Kickrail-Signature", "").split(","))
    t, v1 = parts.get("t", "0"), parts.get("v1", "")
    expected = hmac.new(os.environ["KICKRAIL_WEBHOOK_SECRET"].encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
    if abs(time.time() - int(t)) > 300 or not hmac.compare_digest(v1, expected):
        abort(400)

    event = json.loads(body)
    if event["type"] == "referral.converted":
        ...
    return "", 200

Delivery and retries#

  • Any 2xx response counts as delivered. Anything else, a timeout (10 seconds) or a connection error is a failure. Redirects are not followed.
  • Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours. After that the delivery is marked failed; you can still retry it from the dashboard.
  • Deliveries can arrive more than once and out of order. Use the event id to skip duplicates, and the object's current state (or a fresh GET from the API) rather than the order events arrived in.
  • Answer quickly and do slow work afterwards, in a queue or background job.

Testing locally

Kickrail can't reach localhost. Use a tunnel such as ngrok or Cloudflare Tunnel to get a public https URL, add it as an endpoint, and click Send test.