Agent skill

Campfire Webhooks Skill

Receive and verify Campfire webhooks (campfire.ai, the AI accounting and ERP platform at app.meetcampfire.com; not 37signals' Campfire chat). Use when setting up a Campfire webhook handler, debugging Campfire-Webhook-Signature-v1 verification, or handling topics like Invoice.created, Invoice.paid, Invoice.payment, Bill.created, Bill.paid, CreditMemo.created or JournalEntry.updated. The signature is a hex HMAC-SHA256 over {timestamp}:{raw body}.

Install this skill

npx skills add hookdeck/webhook-skills --skill campfire-webhooks


Campfire is an AI accounting and ERP platform. Its webhooks report created, updated and deleted invoices, bills, credit and debit memos, journal entries and contracts, plus invoice and bill payments.

Not 37signals' Campfire (the team-chat product). This skill is for campfire.ai.

When to Use This Skill

  • How do I receive Campfire webhooks?
  • How do I verify the Campfire-Webhook-Signature-v1 signature?
  • How do I handle Invoice.paid or Bill.created events?
  • Why does my Campfire webhook signature verification fail?
  • What is the difference between Invoice.payment and Invoice.paid?

Verification (core)

Campfire signs the bytes {Campfire-Webhook-Request-Timestamp}:{raw request body} (a colon, not a dot) with HMAC-SHA256, keyed with the webhook's 50-character signing secret as UTF-8 text. The bare lowercase hex digest (no prefix, despite the -v1 in the name) is in Campfire-Webhook-Signature-v1. Missing header, malformed value or mismatch: respond 401.

const crypto = require('crypto');

function verifyCampfireSignature(rawBody, headers, secret) {
  const timestamp = headers['campfire-webhook-request-timestamp'];
  const signature = headers['campfire-webhook-signature-v1'];
  if (!secret || !timestamp || !signature) return false;
  if (!/^[0-9a-fA-F]{64}$/.test(signature)) return false; // validate before decoding

  const message = Buffer.concat([Buffer.from(timestamp, 'latin1'), Buffer.from(':'), rawBody]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(message).digest();
  return crypto.timingSafeEqual(expected, Buffer.from(signature.toLowerCase(), 'hex'));
}

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Gotchas

  • Raw body, as bytes. Express: express.raw({ type: '*/*' }); Next.js: await request.arrayBuffer(); FastAPI: await request.body(). Never JSON.parse / JSON.stringify before verifying, and never decode and re-encode the body.
  • The timestamp is signed exactly as received. Do not trim or reformat the header value.
  • The secret is used as-is. It is the token returned when the webhook is created (or Show on the webhook card). Do not hex- or base64-decode it. It is not your API key, and Campfire generates it: to rotate, delete the webhook and create a new one.
  • Compare safely. Require exactly 64 hex characters (Buffer.from(x, 'hex') silently drops bad characters), lowercase, then crypto.timingSafeEqual on equal-length buffers (Python: hmac.compare_digest on bytes).
  • Timestamp window is opt-in. Campfire recommends a 5-minute tolerance and re-signs each retry, but Hookdeck retries and replays resend the original headers and Hookdeck applies no tolerance, so the examples do not reject old timestamps by default. Set CAMPFIRE_WEBHOOK_TOLERANCE_SECONDS (for example 300; unset or 0 = off) to enable it. The timestamp is Unix seconds.
  • Dispatch on topic. The body is { webhook_uuid, topic, data, changes }. *.deleted carries only {"id": ...} in data and null in changes.

Topics

Exact, case-sensitive strings (28 documented):

ObjectTopics
InvoicesInvoice.created, Invoice.updated, Invoice.deleted, Invoice.payment, Invoice.paid
BillsBill.created, Bill.updated, Bill.deleted, Bill.payment, Bill.paid
Credit memos / linesCreditMemo.{created,updated,deleted}, CreditMemoLine.{created,updated,deleted}
Debit memos / linesDebitMemo.{created,updated,deleted}, DebitMemoLine.{created,updated,deleted}
Journal entriesJournalEntry.created, JournalEntry.updated, JournalEntry.deleted
ContractsContract.created, Contract.updated, Contract.deleted

.payment fires on every increase in amount paid (partial payments included). .paid fires when the document becomes fully paid; then both fire as separate deliveries and no .updated is sent for that change.

Delivery

POST, respond 2xx to acknowledge. 4xx / 5xx fail and are retried up to 5 times about a minute apart; a 404 is not retried. Each retry is re-signed. Not ordered, duplicates possible: be idempotent and treat data as the latest state of the object.

Environment Variables

CAMPFIRE_WEBHOOK_SECRET=your-50-character-signing-secret
# Optional replay window in seconds; unset or 0 = off
CAMPFIRE_WEBHOOK_TOLERANCE_SECONDS=300

Local Development

npx hookdeck-cli listen 3000 campfire --path /webhooks/campfire

Use port 8000 for the FastAPI example. No account required: the CLI creates a guest account on first run and gives you a public URL plus a web UI for inspecting requests. Paste the URL into Settings > Developer > Webhooks in Campfire.

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →