API v1 · OpenAPI 3.1

Build on Furnace Card Studio

A predictable REST API for contacts, companies, opportunities, cards, devices, bookings and analytics, plus signed webhooks for every workspace event.

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.

Your first request
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.

ScopeGrants
contacts:readRead contacts
contacts:writeCreate, update and delete contacts
companies:readRead companies
companies:writeCreate, update and delete companies
opportunities:readRead opportunities
opportunities:writeCreate and update opportunities
profiles:readRead cards and pages
devices:readRead NFC devices
bookings:readRead bookings
events:readRead event workspaces
analytics:readRead the analytics summary
webhooks:writeManage webhook endpoints and REST hook subscriptions

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.

422 Unprocessable Content
{
  "error": {
    "code": "validation_error",
    "message": "Invalid request body.",
    "field_errors": { "email": "Invalid email address" },
    "request_id": "req_4f9a0c…"
  }
}
CodeStatusMeaning
invalid_json400The body isn't valid JSON.
unauthorized401Missing, invalid, expired or revoked API key.
plan_limit402The workspace plan doesn't include the API, or a plan limit (e.g. contacts) was reached.
insufficient_scope403The key lacks the scope the endpoint needs (see details.required_scope).
not_found404No such record in this key's workspace. Records in other workspaces are indistinguishable from missing ones.
duplicate_contact409A contact with that email exists (details.existing_id).
idempotency_conflict409A request with the same Idempotency-Key is still in progress.
payload_too_large413Bodies are limited to 256 KB.
validation_error422Invalid input; field_errors maps each field to a message.
idempotency_key_reused422The Idempotency-Key was used with a different request.
rate_limited429Too many requests; wait Retry-After seconds.
internal_error500Our fault. Safe to retry with the same Idempotency-Key.

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.

Request
curl "https://your-furnace-domain/api/v1/contacts?limit=50&lifecycle_stage=qualified&cursor=eyJ0Ijoi…" \
  -H "Authorization: Bearer fcs_…"
Response
{
  "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

  • GET
    /api/v1/me

    Identify the API key — Returns the workspace, scopes, plan and rate limit of the key making the request.

    any key

Contacts

  • GET
    /api/v1/contacts

    List contacts — Newest first. Paginate with `cursor` = the previous page's `next_cursor`.

    contacts:read
  • POST
    /api/v1/contacts

    Create 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/companies

    List companies

    companies:read
  • POST
    /api/v1/companies

    Create 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/opportunities

    List opportunities

    opportunities:read
  • POST
    /api/v1/opportunities

    Create 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/profiles

    List cards and pages

    profiles:read
  • GET
    /api/v1/profiles/{id}

    Retrieve a card or page

    profiles:read

Devices

  • GET
    /api/v1/devices

    List NFC devices

    devices:read
  • GET
    /api/v1/devices/{id}

    Retrieve a device

    devices:read

Bookings

  • GET
    /api/v1/bookings

    List bookings

    bookings:read
  • GET
    /api/v1/bookings/{id}

    Retrieve a booking

    bookings:read

Events

  • GET
    /api/v1/events

    List event workspaces

    events:read
  • GET
    /api/v1/events/{id}

    Retrieve an event workspace

    events:read

Analytics

  • GET
    /api/v1/analytics/summary

    Outcome summary — Views, taps, scans, leads, meetings, won deals and attributed revenue for a period (default: last 30 days).

    analytics:read

Webhooks

  • GET
    /api/v1/webhooks

    List webhook endpoints

    webhooks:write
  • POST
    /api/v1/webhooks

    Create 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/hooks

    List REST hook subscriptions

    webhooks:write
  • POST
    /api/v1/hooks

    Subscribe 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.

Headers
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…
Body
{
  "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.

Node.js
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);
});
Python
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 "", 200

Rotating 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.

Subscribe
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_…" }
Unsubscribe and sample data
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 Zap

There 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.created

    A contact was created (form, import, scan, API...)

  • contact.updated

    A contact's fields changed

  • contact.deleted

    A contact was deleted

  • contact.tag_added

    A tag was added to a contact

  • contact.qualified

    A contact crossed the qualification score threshold

  • contact.merged

    Two contacts were merged

form

  • form.submitted

    A public form (exchange, quote request...) was submitted

consent

  • consent.changed

    Marketing consent was granted or withdrawn

  • consent.withdrawn

    Marketing consent was withdrawn

booking

  • booking.created

    A meeting was booked

  • booking.canceled

    A meeting was canceled

opportunity

  • opportunity.created

    An opportunity was created

  • opportunity.stage_changed

    An opportunity moved to another stage

  • opportunity.won

    An opportunity was won

  • opportunity.lost

    An opportunity was lost

profile

  • profile.published

    A profile was published

device

  • device.activated

    An NFC device was activated

  • device.reassigned

    An NFC device was reassigned

member

  • member.deactivated

    A team member was deactivated

proposal

  • proposal.accepted

    A proposal was accepted

message

  • message.bounced

    An email bounced

invoice

  • invoice.sent

    A client invoice was sent through Square (emailed or shared as a pay link)

  • invoice.payment_received

    Money was received on a client invoice (deposit, partial or final payment)

  • invoice.paid

    A client invoice was paid in full in Square

  • invoice.refunded

    A client invoice payment was refunded (partially or fully) in Square

  • invoice.canceled

    A 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:

ConnectorEnvironmentRedirect URIScopes requested
HubSpotHUBSPOT_CLIENT_ID HUBSPOT_CLIENT_SECREThttps://your-furnace-domain/api/oauth/hubspot/callbackoauth crm.objects.contacts.read crm.objects.contacts.write
SalesforceSALESFORCE_CLIENT_ID SALESFORCE_CLIENT_SECREThttps://your-furnace-domain/api/oauth/salesforce/callbackapi refresh_token
Google WorkspaceGOOGLE_CLIENT_ID GOOGLE_CLIENT_SECREThttps://your-furnace-domain/api/oauth/google_workspace/callbackopenid email https://www.googleapis.com/auth/calendar.freebusy
Microsoft 365MICROSOFT_CLIENT_ID MICROSOFT_CLIENT_SECREThttps://your-furnace-domain/api/oauth/microsoft_365/callbackopenid email offline_access User.Read Calendars.ReadBasic
SquareSQUARE_CLIENT_ID SQUARE_CLIENT_SECREThttps://your-furnace-domain/api/oauth/square/callbackMERCHANT_PROFILE_READ CUSTOMERS_READ CUSTOMERS_WRITE ORDERS_READ ORDERS_WRITE INVOICES_READ INVOICES_WRITE PAYMENTS_READ
  • SALESFORCE_LOGIN_URL (optional): use https://test.salesforce.com for sandboxes.
  • Square: SQUARE_ENVIRONMENT=sandbox uses Square’s sandbox. Register one app-level webhook at https://your-furnace-domain/api/webhooks/square (invoice and payment.updated events) and set SQUARE_WEBHOOK_SIGNATURE_KEY; SQUARE_WEBHOOK_URL overrides 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_URL is 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.