DocsWebhooks

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:

HeaderMeaning
X-Campfire-EventThe event type, e.g. message.created.
X-Campfire-DeliveryUnique delivery UUID. Safe dedupe key.
X-Campfire-TimestampISO 8601 timestamp of the delivery attempt.
X-Campfire-SignatureHex-encoded HMAC-SHA256 of the raw body, using your signing secret.
User-AgentAlways 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.

message.createdjson
{
  "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.

Node.js (Express)

webhook.jsjs
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) => {
    const signature = req.get('X-Campfire-Signature') ?? '';
    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)

webhook.pypython
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():
    signature = request.headers.get("X-Campfire-Signature", "")

    # 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", 200

Compare 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