Webhooks
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-Deliveryheader. - 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
GETendpoint.
Event catalog
| Event | When it fires |
|---|---|
contact.created | A contact is created via any surface (API, manual, lead/web form, external integrations). |
contact.updated | A contact is updated. |
interaction_log.created | An interaction log is added to a contact. |
web_form.submitted | A 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.
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:
| Header | Description |
|---|---|
X-Craftify-Event | The event name, for example contact.created. |
X-Craftify-Delivery | Unique delivery id; dedupe on this. |
X-Craftify-Signature | HMAC signature: t=<unix seconds>,v1=<hmac-sha256>. |
The body:
{
"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.
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')
}
)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
// $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.
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.
Related
- Contacts — fetch the current state referenced by an event.
- API reference — the webhook management endpoints and envelope schema.