Webhooks

  • Overview
  • Getting started
  • Authentication
  • Rate limits & errors
  • Contacts
  • Interaction logs
  • Webhooks
  • API reference & playground
  • Changelog
  • Legacy endpoints

Webhooks

Webhooks push events to your HTTPS endpoint so your systems react without polling. CraftifyAI POSTs a signed JSON envelope to your URL whenever a subscribed event fires.

Delivery semantics

  • At least once. A delivery can arrive more than once. Dedupe on the X-Craftify-Delivery header.
  • No ordering guarantee. Events may arrive out of order, so do not assume the sequence you receive matches the sequence they happened.
  • Snapshots, not live state. The payload reflects the data at the time the event fired. For the current truth, call the relevant GET endpoint.

Event catalog

EventWhen it fires
contact.createdA contact is created via any surface (API, manual, lead/web form, external integrations).
contact.updatedA contact is updated.
interaction_log.createdAn interaction log is added to a contact.
web_form.submittedA CraftifyAI web form is submitted.

CSV bulk imports do not fire contact.created. If you need to react to imported contacts, reconcile with GET /contacts?updatedSince= instead.

Managing subscriptions

Manage subscriptions in the CraftifyAI web app UI, or through the API with webhooks:manage: GET /webhooks, POST /webhooks, PATCH /webhooks/{id}, DELETE /webhooks/{id}, and POST /webhooks/{id}/test to send a signed ping. The URL must be HTTPS and resolve to a public address. Pass ["*"] as the events to subscribe to everything.

bash
curl -X POST https://api.craftify.ai/v1/webhooks \
  -H "Authorization: Bearer cfy_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/craftify/webhook",
    "events": ["contact.created", "contact.updated"],
    "description": "Sync to booking system"
  }'

The create response includes the signing secret exactly once. Store it now to verify signatures; it cannot be retrieved again.

The delivery envelope

Every delivery is a POST with these headers:

HeaderDescription
X-Craftify-EventThe event name, for example contact.created.
X-Craftify-DeliveryUnique delivery id; dedupe on this.
X-Craftify-SignatureHMAC signature: t=<unix seconds>,v1=<hmac-sha256>.

The body:

json
{
  "id": "evt_6660c1a2f3b4c5d6e7f8a9b0",
  "event": "contact.created",
  "apiVersion": "v1",
  "projectId": "5f8a1c2b3d4e5f6a7b8c9d0e",
  "createdAt": "2026-07-18T18:04:12Z",
  "data": {
    "contact": { "contactId": "CR-100042", "firstName": "Jane", "email": "jane@example.com" }
  }
}

The id is identical across retries of the same event. Respond with any 2xx to acknowledge receipt.

Signature verification

Verify every delivery before trusting it. The X-Craftify-Signature header has the form t=<unix seconds>,v1=<hmac>. Recompute the HMAC-SHA256 of `${t}.${rawBody}` using your signing secret, compare it to v1 with a timing-safe comparison, and reject the delivery if |now - t| > 300 seconds. Always sign the raw request body bytes, never a re-serialized copy.

javascript
const crypto = require('crypto')

// rawBody must be the exact bytes received, not re-serialized JSON.
function verifyCraftifySignature(rawBody, header, secret) {
  if (!header) return false
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('='))
  )
  const t = Number(parts.t)
  const v1 = parts.v1
  if (!t || !v1) return false

  // Reject deliveries outside the 5 minute window.
  if (Math.abs(Date.now() / 1000 - t) > 300) return false

  const signedPayload = `${t}.${rawBody}`
  const expected = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex')

  const a = Buffer.from(expected)
  const b = Buffer.from(v1)
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Express: express.raw keeps req.body as the raw Buffer.
app.post(
  '/craftify/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = req.body.toString('utf8')
    const ok = verifyCraftifySignature(
      raw,
      req.header('X-Craftify-Signature'),
      process.env.CRAFTIFY_WEBHOOK_SECRET
    )
    if (!ok) return res.status(400).send('invalid signature')

    // Dedupe on the delivery id (delivery is at-least-once).
    const deliveryId = req.header('X-Craftify-Delivery')
    const event = JSON.parse(raw)
    // handle event, then acknowledge.
    res.status(200).send('ok')
  }
)
python
import hashlib
import hmac
import os
import time

# raw_body must be the exact bytes received.
def verify_craftify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False
    try:
        parts = dict(kv.split("=", 1) for kv in header.split(","))
        t = int(parts["t"])
        v1 = parts["v1"]
    except (KeyError, ValueError):
        return False

    # Reject deliveries outside the 5 minute window.
    if abs(time.time() - t) > 300:
        return False

    signed_payload = f"{t}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


# Flask example.
from flask import Flask, request

app = Flask(__name__)


@app.post("/craftify/webhook")
def craftify_webhook():
    raw_body = request.get_data()  # raw bytes
    ok = verify_craftify_signature(
        raw_body,
        request.headers.get("X-Craftify-Signature", ""),
        os.environ["CRAFTIFY_WEBHOOK_SECRET"],
    )
    if not ok:
        return "invalid signature", 400

    # Dedupe on request.headers["X-Craftify-Delivery"].
    return "ok", 200
php
<?php
// $rawBody must be the exact bytes received.
function verify_craftify_signature(string $rawBody, string $header, string $secret): bool
{
    if ($header === '') {
        return false;
    }
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
        $parts[$k] = $v;
    }
    if (empty($parts['t']) || empty($parts['v1'])) {
        return false;
    }
    $t = (int) $parts['t'];

    // Reject deliveries outside the 5 minute window.
    if (abs(time() - $t) > 300) {
        return false;
    }

    $signedPayload = $t . '.' . $rawBody;
    $expected = hash_hmac('sha256', $signedPayload, $secret);
    return hash_equals($expected, $parts['v1']);
}

$rawBody = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_CRAFTIFY_SIGNATURE'] ?? '';

if (!verify_craftify_signature($rawBody, $header, getenv('CRAFTIFY_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit('invalid signature');
}

// Dedupe on $_SERVER['HTTP_X_CRAFTIFY_DELIVERY'].
http_response_code(200);
echo 'ok';

Retries and auto-disable

Failed deliveries are retried with exponential backoff for about six hours. A subscription that keeps failing is automatically set to disabled by the system after sustained failures. Re-enable it with a PATCH setting status: "active" once your endpoint is healthy. The delivery log is available in the web app UI, and consecutiveFailures, lastSuccessAt, and lastFailureReason are on the webhook object.

bash
curl -X PATCH https://api.craftify.ai/v1/webhooks/wh_123 \
  -H "Authorization: Bearer cfy_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'

Idempotency

Because delivery is at least once, dedupe on the X-Craftify-Delivery header. Record the delivery id and skip any delivery you have already processed.

  • Contacts — fetch the current state referenced by an event.
  • API reference — the webhook management endpoints and envelope schema.

© 2026 Craftify AI. All rights reserved.