Guide to Campfire Webhooks: Features and Best Practices
Campfire webhooks tell your systems when accounting data changes: an invoice is paid, a bill is created, a journal entry is edited or a contract is deleted. Campfire is an AI accounting and ERP platform, and webhooks let you keep billing tools, data warehouses and approval workflows in sync without polling its API.
This guide covers how Campfire webhooks are created and signed, the topics and payload, the retry policy, and the practices that keep a Campfire receiver reliable.
What are Campfire webhooks?
A Campfire webhook is an HTTPS endpoint subscribed to a list of topics, such as Invoice.paid or Bill.created. When a matching change happens, Campfire sends a signed POST with a JSON body describing the object and what changed. Managing webhooks, in the app or the API, needs an admin user.
Each delivery is signed with the webhook's own secret. The signed message is the Campfire-Webhook-Request-Timestamp header, a colon, and the raw body. Campfire hex-encodes the HMAC-SHA256 and sends it in Campfire-Webhook-Signature-v1. Despite the -v1 in the header name, the value is a bare hex string with no prefix.
Campfire webhook features
| Feature | Details |
|---|---|
| Configuration | Settings > Developer > Webhooks, or POST /integrations/api/v1/webhook. Admins only |
| Subscription model | A list of exact topic names per webhook |
| Signature header | Campfire-Webhook-Signature-v1, bare hex |
| Timestamp header | Campfire-Webhook-Request-Timestamp, Unix seconds |
| Scheme | HMAC-SHA256 |
| Signed content | <timestamp>:<raw body> (a colon, not a dot) |
| Secret | 50 characters, generated by Campfire. Can't be set or changed |
| Other headers | Campfire-Webhook-UUID, the webhook's UUID, the same on every delivery |
| Payload | webhook_uuid, topic, data, changes |
| Success | Any 2xx. Any 4xx or 5xx is a failure |
| Retries | Up to 5, roughly a minute apart. A 404 isn't retried. Each retry is re-signed |
| Ordering | Not guaranteed. Duplicates are possible |
| Delivery log | Recent Events on the webhooks page, or the last 25 through the API |
| Source IPs | Not published |
Common events
Campfire documents 28 topics. Topic names are exact, case-sensitive strings.
| Object | Topics |
|---|---|
| Invoices | Invoice.created, Invoice.updated, Invoice.deleted, Invoice.payment, Invoice.paid |
| Bills | Bill.created, Bill.updated, Bill.deleted, Bill.payment, Bill.paid |
| Credit memos | CreditMemo.created, .updated, .deleted, and the same for CreditMemoLine |
| Debit memos | DebitMemo.created, .updated, .deleted, and the same for DebitMemoLine |
| Journal entries | JournalEntry.created, JournalEntry.updated, JournalEntry.deleted |
| Contracts | Contract.created, Contract.updated, Contract.deleted |
The payment topics behave differently from the rest. .payment fires whenever the amount paid goes up, including partial payments. .paid fires when the invoice or bill becomes fully paid, and at that moment Campfire sends both .paid and .payment as separate deliveries, with no .updated for the change.
Every delivery has four fields. Here is Campfire's Invoice.paid example, with the invoice object shortened:
{
"webhook_uuid": "3c88df19-1c1f-4b1c-b4a7-768793055a60",
"topic": "Invoice.paid",
"data": {
"id": 12345,
"invoice_number": "INV-1042",
"payment_status": "paid",
"amount_paid": 1500.0
},
"changes": {
"payment_status": { "old": "open", "new": "paid" },
"amount_paid": { "old": 500.0, "new": 1500.0 }
}
}
data is the full object in the same shape as the matching GET endpoint. For *.deleted topics it holds only {"id": ...} and changes is null. On *.created topics every field appears in changes with "old": null.
Setting up Campfire webhooks
In the app:
- Go to Settings > Developer > Webhooks and click New Webhook.
- Enter your HTTPS URL, check Active, and select topics. You can select a whole group, such as Invoices, or single topics.
- Save. The webhook card shows an HMAC-SHA256 Secret. Click Show and store it, for example as
CAMPFIRE_WEBHOOK_SECRET.
Through the API, with an admin API key:
curl -X POST https://api.meetcampfire.com/integrations/api/v1/webhook \
-H "Authorization: Token $CAMPFIRE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/campfire",
"active": true,
"topics": [{"name": "Invoice.paid"}, {"name": "Bill.created"}]
}'
The response includes the webhook's integer id, its uuid and the signing secret as token. url and active are required on create and update, and updating topics replaces the whole list, so send every topic the webhook should keep. Set "active": false to pause deliveries. The secret can't be changed: to rotate it, delete the webhook and create a new one.
With Hookdeck. Campfire is a supported source type in Hookdeck. Create a source with the Campfire type, paste the signing secret into it, and use the Hookdeck source URL as the webhook URL in Campfire. The source accepts POST only and checks Campfire-Webhook-Signature-v1 against <timestamp>:<raw body>. A request that fails the check still gets a 200 from Hookdeck, but Hookdeck marks it as failed verification and doesn't deliver it to your service.
Securing Campfire webhooks
Build the signed message from the timestamp header, a literal :, and the raw body bytes. Compute an HMAC-SHA256 with the secret as a UTF-8 string and compare the hex digests in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyCampfireSignature(rawBody, signature, timestamp, secret) {
if (!signature || !timestamp || !secret) return false;
if (!/^[0-9a-f]{64}$/i.test(signature)) return false; // validate before decoding
// A colon, not a dot, between the timestamp and the body.
const signed = Buffer.concat([Buffer.from(`${timestamp}:`, 'latin1'), rawBody]);
const expected = crypto.createHmac('sha256', secret).update(signed).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// Optional replay check, off by default (0). Hookdeck retries and replays resend the original timestamp.
const TOLERANCE_SECONDS = Number(process.env.CAMPFIRE_WEBHOOK_TOLERANCE_SECONDS || 0);
const app = express();
app.post('/webhooks/campfire', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.CAMPFIRE_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500); // fail closed
const timestamp = req.get('campfire-webhook-request-timestamp');
if (!verifyCampfireSignature(req.body, req.get('campfire-webhook-signature-v1'), timestamp, secret)) {
return res.sendStatus(401);
}
if (TOLERANCE_SECONDS > 0 && Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.topic, then process asynchronously.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import re
HEX_64 = re.compile(r"[0-9a-fA-F]{64}")
def verify_campfire_signature(raw_body: bytes, signature, timestamp, secret: str) -> bool:
if not signature or not timestamp or not secret:
return False
if not HEX_64.fullmatch(signature):
return False
signed = timestamp.encode("latin-1") + b":" + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
return hmac.compare_digest(bytes.fromhex(signature), expected)
Campfire recommends rejecting timestamps older than 5 minutes, and notes that its own retries are re-signed with a fresh timestamp, so the check doesn't reject Campfire's retries. Hookdeck works differently: it applies no timestamp tolerance, and its retries and replays resend the original headers, including the original timestamp. If Hookdeck sits in front of your service, leave the window off, or set it wide enough to cover your Hookdeck retry schedule, and deduplicate instead.
Campfire's Node sample compares the signatures with crypto.timingSafeEqual after a length check on the strings, and builds the message from rawBody in a template string. Building it from bytes, as above, avoids any re-encoding of the body.
Make Campfire webhooks production-ready. Hookdeck Event Gateway verifies the
Campfire-Webhook-Signature-v1header, stores every invoice, bill and journal entry event, and delivers it to your services with retries you control.
Campfire webhook limitations and pain points
Five retries, a minute apart
The Problem: A short outage on your side loses events.
Why It Happens: Campfire retries a failed delivery up to five times, roughly one minute apart, and treats any 4xx or 5xx as a failure. A 404 isn't retried at all. A deploy or outage of more than a few minutes uses up every attempt.
Workarounds:
- Keep the webhook route up through deploys, and never let it return
404. - Check Recent Events, or
GET /integrations/api/v1/webhook/{id}/events, after an outage, and fetch anything that failed from the API.
How Hookdeck Can Help: Hookdeck answers Campfire with a 200 as soon as a request arrives and stores it, then delivers to your service with automatic retries over a window you set, and lets you replay any event.
Out-of-order and duplicate deliveries
The Problem: An older update overwrites a newer one, or the same payment is recorded twice.
Why It Happens: Campfire doesn't guarantee order, and retries can resend an event your handler already processed. A paid invoice also produces two deliveries, .paid and .payment.
Workarounds:
- Treat
dataas the current state of the object, as Campfire suggests, rather than applyingchangesas increments. - Decide which of
.paidand.paymentdrives each action, so a full payment isn't counted twice.
How Hookdeck Can Help: Deduplication can drop repeat requests before they reach your service, and filters can route Invoice.paid and Invoice.payment to different destinations.
No delivery ID
The Problem: There's no header or field that identifies one event.
Why It Happens: Campfire-Webhook-UUID and webhook_uuid identify the webhook, not the event, and are the same on every delivery.
Workarounds:
- Build an idempotency key from
topic,data.idand a version field from the object, such as an updated-at timestamp if the object has one.
How Hookdeck Can Help: Hookdeck gives every request its own ID and keeps the full headers and body, so you can trace and replay a single delivery.
A secret you can't rotate in place
The Problem: Rotating the secret interrupts deliveries.
Why It Happens: Campfire generates the secret and won't change it. The only way to rotate is to delete the webhook and create a new one with a new secret.
Workarounds:
- Create the new webhook first, update your receiver to accept both secrets, then delete the old webhook.
How Hookdeck Can Help: With Hookdeck in front, the secret lives on the source, and services behind it don't need to know it.
Best practices
Verify against the raw body
Campfire warns that re-serialising the JSON changes whitespace and key order, which breaks the signature. Use express.raw() in Express, await request.arrayBuffer() in a Next.js route handler, and await request.body() in FastAPI.
Subscribe to what you use
Updating topics replaces the full list, so keep the list in code and send it whole. Subscribe to the payment topics only if you handle partial payments, and answer 2xx to anything you don't handle.
Use data as the source of truth
Because order isn't guaranteed, write the object in data as the latest state, and compare a version or timestamp field before overwriting newer data. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Campfire asks you to respond quickly and process slow work afterwards. Verify, store the event, return 200, then do the work. See why to process webhooks asynchronously.
Conclusion
Campfire signs each delivery with Campfire-Webhook-Signature-v1, a bare hex HMAC-SHA256 of <timestamp>:<raw body>, keyed with the webhook's 50-character secret. Verify against the raw bytes with a colon, route on topic, treat data as the latest state, and plan for a retry policy that gives up after five attempts about a minute apart.
Hookdeck Event Gateway sits in front of your receiver, checks every Campfire signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Campfire webhooks reliably in minutes.