Overview
All requests go to https://your-furnace-domain/api/v1 over HTTPS and exchange JSON. Field names are snake_case, timestamps are ISO 8601 in UTC, money is in minor units (value_cents) with an ISO currency code.
curl https://your-furnace-domain/api/v1/me \
-H "Authorization: Bearer fcs_your_key"The machine-readable contract is the OpenAPI 3.1 document, generated from the same schemas that validate requests, so it can’t drift from behavior. Import it into Postman, Insomnia or a client generator.
Authentication
Workspace admins create keys in Settings → API keys & webhooks. A key is shown once, stored only as a hash, belongs to exactly one workspace, and can be given an expiry and revoked at any time. Send it as a bearer token:
Authorization: Bearer fcs_…Give each key only the scopes it needs. A request outside the key's scopes fails with 403 insufficient_scope.
The API is available from the Individual plan. A key whose workspace no longer includes API access receives 402 plan_limit.
Errors
Errors always use the same envelope and a stable machine-readable code. Include request_id (also the Request-Id header) when contacting support.
{
"error": {
"code": "validation_error",
"message": "Invalid request body.",
"field_errors": { "email": "Invalid email address" },
"request_id": "req_4f9a0c…"
}
}Pagination
List endpoints return newest first in pages of limit (1–100, default 25). Pass the returned next_cursor as cursor to get the next page; it's null on the last page. Cursors are stable while records are created concurrently.
curl "https://your-furnace-domain/api/v1/contacts?limit=50&lifecycle_stage=qualified&cursor=eyJ0Ijoi…" \
-H "Authorization: Bearer fcs_…"{
"object": "list",
"data": [ { "object": "contact", "id": "…", "full_name": "Jordan Lee", … } ],
"has_more": true,
"next_cursor": "eyJ0IjoiMjAyNi0xMC0wOVQxNDozMDowMC4xMjM0NTZaIiwiaWQiOiI…"
}Idempotency
Every POST accepts an Idempotency-Key header (any unique string up to 255 characters, e.g. a UUID). Retrying with the same key and body within 24 hours returns the original response, including errors, with Idempotent-Replayed: true, and never creates a second record. Reusing a key with a different body is rejected with 422 idempotency_key_reused.
curl -X POST https://your-furnace-domain/api/v1/contacts \
-H "Authorization: Bearer fcs_…" \
-H "Idempotency-Key: 6b1f2c3e-badge-scan-0042" \
-H "Content-Type: application/json" \
-d '{"full_name":"Jordan Lee","email":"jordan@northwind.example","company_name":"Northwind"}'Rate limits
Limits apply per key, per minute. Every response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset (seconds); a 429 also includes Retry-After.
Individual
60 /min
Business
120 /min
Team
300 /min
Agency
600 /min
Enterprise
1200 /min
Endpoints
Required scopes are shown next to each endpoint. Full request and response schemas are in the OpenAPI document.
Account
- GETany key
/api/v1/meIdentify the API key — Returns the workspace, scopes, plan and rate limit of the key making the request.
Contacts
- GET
/api/v1/contactsList contacts — Newest first. Paginate with `cursor` = the previous page's `next_cursor`.
contacts:read - POST
/api/v1/contactsCreate a contact — Send an `Idempotency-Key` header to make retries safe. Returns 409 `duplicate_contact` (with `details.existing_id`) when the email already exists.
contacts:write - GET
/api/v1/contacts/{id}Retrieve a contact
contacts:read - PATCH
/api/v1/contacts/{id}Update a contact — Only the fields you send change. `null` clears a field. `custom_fields` are merged.
contacts:write - DELETE
/api/v1/contacts/{id}Delete a contact
contacts:write
Companies
- GET
/api/v1/companiesList companies
companies:read - POST
/api/v1/companiesCreate a company
companies:write - GET
/api/v1/companies/{id}Retrieve a company
companies:read - PATCH
/api/v1/companies/{id}Update a company
companies:write - DELETE
/api/v1/companies/{id}Delete a company
companies:write
Opportunities
- GET
/api/v1/opportunitiesList opportunities
opportunities:read - POST
/api/v1/opportunitiesCreate an opportunity
opportunities:write - GET
/api/v1/opportunities/{id}Retrieve an opportunity
opportunities:read - PATCH
/api/v1/opportunities/{id}Update an opportunity — Change `stage_id` or `status` to move the deal; won/lost emit `opportunity.won` / `opportunity.lost`.
opportunities:write
Profiles
- GET
/api/v1/profilesList cards and pages
profiles:read - GET
/api/v1/profiles/{id}Retrieve a card or page
profiles:read
Devices
- GET
/api/v1/devicesList NFC devices
devices:read - GET
/api/v1/devices/{id}Retrieve a device
devices:read
Bookings
- GET
/api/v1/bookingsList bookings
bookings:read - GET
/api/v1/bookings/{id}Retrieve a booking
bookings:read
Events
- GET
/api/v1/eventsList event workspaces
events:read - GET
/api/v1/events/{id}Retrieve an event workspace
events:read
Analytics
- GET
/api/v1/analytics/summaryOutcome summary — Views, taps, scans, leads, meetings, won deals and attributed revenue for a period (default: last 30 days).
analytics:read
Webhooks
- GET
/api/v1/webhooksList webhook endpoints
webhooks:write - POST
/api/v1/webhooksCreate a webhook endpoint — The response includes the signing `secret` once. Use `["*"]` to receive every event.
webhooks:write - GET
/api/v1/webhooks/{id}Retrieve a webhook endpoint
webhooks:write - PATCH
/api/v1/webhooks/{id}Update a webhook endpoint — Set `active: true` to re-enable an endpoint that was disabled after repeated failures.
webhooks:write - DELETE
/api/v1/webhooks/{id}Delete a webhook endpoint
webhooks:write
REST hooks
- GET
/api/v1/hooksList REST hook subscriptions
webhooks:write - POST
/api/v1/hooksSubscribe a REST hook — Used by Zapier, Make and n8n triggers. Deliveries are signed exactly like webhooks.
webhooks:write - DELETE
/api/v1/hooks/{id}Unsubscribe a REST hook — Idempotent: returns 200 even if the subscription was already removed.
webhooks:write - GET
/api/v1/hooks/samples/{event}Sample payloads for an event — Recent real records when available (for building a Zap), otherwise a labelled example.
webhooks:write
Webhooks
Add endpoints in Integrations → Webhooks (or POST /api/v1/webhooks) and choose events, or * for all. Each delivery is a POST with a JSON envelope; records referenced by the event are expanded with the same shape the REST API returns.
Content-Type: application/json
User-Agent: FurnaceCardStudio/1.0
Furnace-Event-Id: 1f0c6d1e-… (same on every retry)
Furnace-Event-Type: contact.created
Furnace-Delivery-Id: 7a2b…
Furnace-Attempt: 1
Furnace-Signature: t=1760020200,v1=5d41402abc4b2a76b9719d911017c592…{
"id": "1f0c6d1e-…",
"type": "contact.created",
"created_at": "2026-10-09T14:30:00.000Z",
"organization_id": "…",
"data": {
"contact_id": "…",
"source": "exchange_form",
"contact": { "object": "contact", "id": "…", "full_name": "Jordan Lee", "email": "jordan@northwind.example", … }
}
}- Acknowledge fast: return any 2xx within 10 seconds, then process asynchronously. Redirects aren't followed.
- Retries: failed attempts are retried with exponential backoff (about 30s, 1m, 2m, 4m, 8m, 16m, 32m; 8 attempts over roughly an hour).
- At-least-once: de-duplicate on the event id; deliveries can arrive out of order.
- Auto-disable: an endpoint is disabled after 10 consecutive deliveries fail every retry; admins are notified. Re-enable it and use Redeliver from the delivery log to replay missed events.
- Test & inspect: “Send test event” delivers a signed
webhook.test; the log keeps request headers, body and response for 30 days.
Verifying signatures
Furnace-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw request body> keyed with your endpoint's signing secret (the full whsec_… string). Compute it over the exact bytes received, compare in constant time, and reject timestamps older than five minutes to stop replays.
import crypto from "node:crypto";
export function verifyFurnaceSignature(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
// Express: verify against the raw bytes, before any JSON parsing.
app.post("/webhooks/furnace", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verifyFurnaceSignature(body, req.get("Furnace-Signature") ?? "", process.env.FURNACE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
// De-duplicate on event.id (also sent as Furnace-Event-Id), queue the work, answer fast.
res.sendStatus(200);
});import hashlib, hmac, os, time
from flask import Flask, abort, request
def verify_furnace_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
app = Flask(__name__)
@app.post("/webhooks/furnace")
def furnace_webhook():
if not verify_furnace_signature(request.get_data(), request.headers.get("Furnace-Signature", ""), os.environ["FURNACE_WEBHOOK_SECRET"]):
abort(400)
event = request.get_json() # de-duplicate on event["id"]
return "", 200Rotating the secret takes effect immediately; deploy the new secret to your receiver before rotating, or accept both during the switch.
REST hooks (Zapier, Make, n8n)
Automation platforms subscribe and unsubscribe programmatically. Create a key with the webhooks:write scope (choose the platform under “Used by” so the integration shows as connected). Subscriptions are signed and retried exactly like webhooks, and stop when the key is revoked.
POST https://your-furnace-domain/api/v1/hooks
{ "target_url": "https://hooks.zapier.com/hooks/catch/123/abc/", "event": "contact.created" }
201 Created
{ "object": "hook", "id": "8d3e…", "event": "contact.created", "target_url": "https://hooks.zapier.com/…", "active": true, "secret": "whsec_…" }DELETE https://your-furnace-domain/api/v1/hooks/8d3e… → 200 (also when already removed)
GET https://your-furnace-domain/api/v1/hooks/samples/contact.created → recent real payloads for building a ZapThere are no listings in the Zapier, Make or n8n directories yet. Their generic webhook and HTTP modules work today with these endpoints; see each platform's page under Integrations for step-by-step setup.
Event catalog
Every event below can be delivered to webhooks and REST hooks. data always includes the ids of the records involved.
contact
contact.createdA contact was created (form, import, scan, API...)
contact.updatedA contact's fields changed
contact.deletedA contact was deleted
contact.tag_addedA tag was added to a contact
contact.qualifiedA contact crossed the qualification score threshold
contact.mergedTwo contacts were merged
form
form.submittedA public form (exchange, quote request...) was submitted
consent
consent.changedMarketing consent was granted or withdrawn
consent.withdrawnMarketing consent was withdrawn
booking
booking.createdA meeting was booked
booking.canceledA meeting was canceled
opportunity
opportunity.createdAn opportunity was created
opportunity.stage_changedAn opportunity moved to another stage
opportunity.wonAn opportunity was won
opportunity.lostAn opportunity was lost
profile
profile.publishedA profile was published
device
device.activatedAn NFC device was activated
device.reassignedAn NFC device was reassigned
member
member.deactivatedA team member was deactivated
proposal
proposal.acceptedA proposal was accepted
message
message.bouncedAn email bounced
invoice
invoice.sentA client invoice was sent through Square (emailed or shared as a pay link)
invoice.payment_receivedMoney was received on a client invoice (deposit, partial or final payment)
invoice.paidA client invoice was paid in full in Square
invoice.refundedA client invoice payment was refunded (partially or fully) in Square
invoice.canceledA client invoice was canceled
Self-hosting and platform operators
Operator setup
Connectors that use OAuth need an app registered with the provider. Until both variables are set, the connector shows Requires configuration with the exact names. Register these redirect URIs:
SALESFORCE_LOGIN_URL(optional): usehttps://test.salesforce.comfor sandboxes.- Square:
SQUARE_ENVIRONMENT=sandboxuses Square’s sandbox. Register one app-level webhook athttps://your-furnace-domain/api/webhooks/square(invoice andpayment.updatedevents) and setSQUARE_WEBHOOK_SIGNATURE_KEY;SQUARE_WEBHOOK_URLoverrides the signed URL behind proxies. WEBHOOK_AUTO_DISABLE_AFTER(optional, default 10): consecutive failed deliveries before an endpoint is disabled.- Provider webhooks (Calendly, Mailchimp, Shopify) are only registered automatically when
APP_URLis a public HTTPS URL. - API-key connectors (Pipedrive, Mailchimp, Brevo, Stripe, Shopify, Calendly, Slack, Teams) need no operator setup; each workspace pastes its own credentials, which are encrypted with
APP_SECRET.