Core concepts
Webhooks.
Campfire will POST a JSON payload to a URL you control whenever something interesting happens. Deliveries are signed, retried with backoff, and idempotent — each carries a delivery ID that is safe to dedupe against.
Anatomy of a delivery
Every webhook delivery is a POST with Content-Type: application/json and the following headers:
| Header | Meaning |
|---|---|
X-Campfire-Event | The event type, e.g. message.created. |
X-Campfire-Delivery | Unique delivery UUID. Safe dedupe key. |
X-Campfire-Timestamp | ISO 8601 timestamp of the delivery attempt. |
X-Campfire-Signature | Hex-encoded HMAC-SHA256 of the raw body, using your signing secret. |
X-Campfire-Signature-256 | The same HMAC in the legacy sha256=<hex> form. Sent during the transition window only — see Verify the signature. |
User-Agent | Always Campfire-Webhooks/1.0. |
A 2xx response is treated as success. Any other status — or a timeout greater than the configured budget — triggers a retry with exponential backoff. We stop retrying after the configured maximum attempts, which is visible on the delivery record in the dashboard.
Payload shape
Every payload has the same envelope. The data field carries the event-specific body.
{
"event": "message.created",
"id": "evt_8a1d8e2c-8f2a-4a8b-9c0e-6a1d9e2c3f4b",
"created_at": "2026-04-17T14:02:31.000Z",
"organization_id": "5515fbe0-745b-4b01-9ed4-828767c84524",
"data": {
"message": {
"id": "msg_01HYT4...",
"conversation_id": "cnv_01HYT3...",
"sender_type": "visitor",
"sender_id": "vis_01HYT2...",
"content": "Hi — I can’t reset my password.",
"created_at": "2026-04-17T14:02:30.842Z"
}
}
}Verify the signature
The signature is a hex-encoded HMAC-SHA256 of the raw request body using your signing secret. Always compute the HMAC against the untouched bytes — if your framework parses the body as JSON before you see it, re-stringifying will produce a different signature and verification will fail.
Every delivery carries the digest in two headers while the transition window is open — 2026-09-19 through 2027-03-31:
X-Campfire-Signature— the raw hex digest. This is the contract, and it does not change.X-Campfire-Signature-256— the same digest prefixed withsha256=. Older automated deliveries used this form, so it is emitted alongside the raw value for the window and removed after2027-03-31.
A receiver that prefers X-Campfire-Signature and falls back to X-Campfire-Signature-256 with the optional sha256= prefix stripped works for the whole window and keeps working after it closes.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Capture the raw body so we can verify the signature against it.
app.post(
'/webhooks/campfire',
express.raw({ type: 'application/json' }),
(req, res) => {
// Prefer the documented header; fall back to the transition header.
const signature =
req.get('X-Campfire-Signature') ??
(req.get('X-Campfire-Signature-256') ?? '').replace(/^sha256=/, '');
const expected = crypto
.createHmac('sha256', process.env.CAMPFIRE_SIGNING_SECRET)
.update(req.body) // Buffer of the raw body
.digest('hex');
const sigBuf = Buffer.from(signature, 'utf8');
const expBuf = Buffer.from(expected, 'utf8');
const ok =
sigBuf.length === expBuf.length &&
crypto.timingSafeEqual(sigBuf, expBuf);
if (!ok) return res.status(401).send('invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event ...
res.status(200).send('ok');
},
);Python (Flask)
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["CAMPFIRE_SIGNING_SECRET"].encode()
@app.post("/webhooks/campfire")
def handle_campfire():
# Prefer the documented header; fall back to the transition header.
signature = request.headers.get("X-Campfire-Signature", "")
if not signature:
signature = request.headers.get("X-Campfire-Signature-256", "")
signature = signature.removeprefix("sha256=")
# Use the raw bytes. Do not re-serialize the parsed JSON.
expected = hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401)
event = request.get_json()
# ... handle event ...
return "ok", 200Compare with a constant-time check (timingSafeEqual / hmac.compare_digest). Never use a plain string equality — it leaks information through timing and makes your endpoint exploitable.
Event catalogue
Subscribe to only the events you need. You can add or remove events on an existing subscription without a new signing secret.
Conversation
- conversation.createdA new conversation was opened.
- conversation.updatedAny conversation field changed (status, priority, assignee, tags).
- conversation.resolvedThe conversation was resolved by an agent or AI.
- conversation.reopenedA resolved conversation was reopened.
- conversation.assignedThe conversation was assigned to an agent or team.
- conversation.unassignedAssignment was removed.
- conversation.priority_changedPriority moved (e.g. urgent → high).
- conversation.tag_addedA tag was attached to the conversation.
- conversation.tag_removedA tag was detached.
Message
- message.createdA new message — from a visitor, an agent, or AI — was persisted.
- message.updatedA message was edited.
- message.deletedA message was deleted.
Visitor
- visitor.identifiedA visitor became a known user via widget identification.
- visitor.blockedA visitor was blocked from messaging.
- visitor.unblockedA previously blocked visitor was allowed back.
Ticket
- ticket.createdA ticket was created from a conversation.
- ticket.updatedTicket fields changed.
- ticket.resolvedTicket resolved.
- ticket.closedTicket closed (resolved + no further action).
Agent
- agent.onlineAn agent came online.
- agent.offlineAn agent went offline.
- agent.assignedAn agent was assigned conversations via routing.
AI
- ai.response_generatedAn AI draft or automated reply was produced.
- ai.handoff_triggeredAI handed off to a human agent.
Mention
- mention.createdA teammate was @-mentioned on a conversation note.
Next