Features How It Works Pricing Free Tools Blog About Contact
Sign in Start free trial

API Documentation

REST API and webhook reference for integrating with forquotez. Available on Enterprise plans.

Authentication

All API requests require a bearer token in the Authorization header. Create tokens in Settings > API and Webhooks.

curl -H "Authorization: Bearer YOUR_API_TOKEN" \
     -H "Accept: application/json" \
     https://forquotez.com/api/v1/quotes

Tokens are scoped to the creating user's organization. All data returned is limited to that organization.

Rate Limits

60 requests per minute per token. When exceeded, the API returns 429 Too Many Requests with a Retry-After header.

Quotes

GET /api/v1/quotes

List quotes. Supports pagination and status filtering.

Parameters: status (optional: draft, sent, viewed, signed, declined, expired, voided), page

GET /api/v1/quotes/:id

Get a single quote with line items.

Example response:

{
  "data": {
    "id": 42,
    "status": "sent",
    "billing_cycle": "monthly",
    "prospect_name": "Jane Smith",
    "prospect_email": "jane@example.com",
    "prospect_company": "Acme Corp",
    "total_monthly_cents": 319300,
    "total_onetime_cents": 0,
    "discount_savings_cents": 0,
    "share_url": "https://forquotez.com/q/01HX7...",
    "expires_at": "2026-05-14T00:00:00+00:00",
    "created_at": "2026-04-14T18:30:00+00:00",
    "form": { "id": 5, "name": "SaaS Starter Quote" },
    "line_items": [
      {
        "label": "Platform License (3 Users)",
        "description": "3 Users - ~1,500 events/mo",
        "quantity": 3,
        "unit_price_cents": 16700,
        "total_cents": 50100,
        "is_recurring": true,
        "is_locked": false
      }
    ],
    "creator": { "id": 2, "name": "Emerson Scherer" }
  }
}

Contacts

GET /api/v1/contacts

Parameters: email (exact match), company_id, page

GET /api/v1/contacts/:id

Returns contact with associated company.

POST /api/v1/contacts

Create a new contact.

Request body:

{
  "first_name": "Jane",
  "last_name": "Smith",
  "email": "jane@example.com",
  "phone": "+1-555-0123",
  "job_title": "VP of Operations",
  "company_id": 7
}

Required: first_name. All other fields optional.

Companies

GET /api/v1/companies

Parameters: domain (exact match), page

GET /api/v1/companies/:id

Returns company details including domain.

Deals

GET /api/v1/deals

Parameters: status (open, won, lost), pipeline_id, page

PATCH /api/v1/deals/:id

Update a deal. All fields optional.

{
  "name": "Acme Corp Renewal",
  "value_cents": 500000,
  "status": "won",
  "pipeline_stage_id": 12
}

Forms

GET /api/v1/forms

Parameters: status (active, draft, archived), page

GET /api/v1/forms/:id

Returns form details including block count.

Webhooks

Configure webhook endpoints in Settings > API and Webhooks. When a subscribed event fires, forquotez POSTs a JSON payload to your endpoint.

Available events

EventDescription
quote.createdA quote was saved from the runner
quote.sentA quote was marked as sent or sent for signing
quote.viewedA prospect viewed the quote for the first time
quote.signedAll required signatures were completed
quote.declinedA prospect declined the quote
quote.expiredA quote passed its expiration date
quote.voidedA manager or admin voided the quote
signature.sentA signature request was sent to signers
signature.completedAll signers completed, signed PDF compiled
deal.createdA deal was created in the CRM
deal.stage_changedA deal moved to a different pipeline stage
contact.createdA contact was created in the CRM

Payload format

{
  "event": "quote.signed",
  "timestamp": 1718384400,
  "data": {
    "quote_id": 42,
    "status": "signed",
    "prospect_name": "Jane Smith",
    "prospect_email": "jane@example.com",
    "total_monthly_cents": 319300
  }
}

Headers

X-Forquotez-SignatureHMAC-SHA256 of the request body using your endpoint's signing secret
X-Forquotez-TimestampUnix timestamp when the payload was generated
X-Forquotez-EventThe event name (e.g., quote.signed)
Content-Typeapplication/json

Webhook Verification

Verify that webhook payloads originated from forquotez by checking the HMAC signature. The signing secret is shown in your webhook endpoint settings.

  • The signature is a hex-encoded HMAC-SHA256 of the raw request body, keyed with your signing secret. Compute it over the bytes you received, before any JSON parsing.
  • Compare signatures with a constant-time function (hash_equals, crypto.timingSafeEqual, hmac.compare_digest), never == or !==.
  • Keep the secret in an environment variable, not in source code.

PHP

// Read the raw body exactly as received. Do not decode and re-encode it first.
$payload   = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_FORQUOTEZ_SIGNATURE'] ?? '';
$secret    = getenv('FORQUOTEZ_WEBHOOK_SECRET');

$expected = hash_hmac('sha256', $payload, $secret);

// hash_equals compares in constant time.
if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($payload, true);

// Replay protection: use the timestamp INSIDE the signed body, not the header.
if (abs(time() - (int) ($event['timestamp'] ?? 0)) > 300) {
    http_response_code(401);
    exit('Payload too old');
}

// Process $event['event'] and $event['data']
http_response_code(200);

Node.js

const crypto = require('crypto');
const express = require('express');
const app = express();

const secret = process.env.FORQUOTEZ_WEBHOOK_SECRET;

// express.raw keeps the body as the exact bytes forquotez signed. Do NOT use
// express.json() on this route and re-serialize: JSON.stringify(req.body) can
// differ from the original bytes (key order, spacing, escaping) and the
// signature will not match.
app.post('/webhooks/forquotez', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body; // Buffer
  const received = Buffer.from(String(req.headers['x-forquotez-signature'] || ''), 'hex');

  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest();

  // timingSafeEqual throws on different lengths, so check length first.
  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
    return res.status(401).send('Invalid signature');
  }

  const { event, timestamp, data } = JSON.parse(rawBody.toString('utf8'));

  // Replay protection: use the timestamp inside the signed body, not the header.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(401).send('Payload too old');
  }

  // Process event and data
  res.status(200).send('OK');
});

Python

import hmac, hashlib, json, os, time

SECRET = os.environ['FORQUOTEZ_WEBHOOK_SECRET'].encode()

def verify_webhook(request):  # Flask
    # Raw bytes exactly as received. Do not parse and re-serialize first.
    raw_body = request.get_data()
    signature = request.headers.get('X-Forquotez-Signature', '')

    expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()

    # compare_digest compares in constant time.
    if not hmac.compare_digest(expected, signature):
        return 'Invalid signature', 401

    event = json.loads(raw_body)

    # Replay protection: use the timestamp inside the signed body, not the header.
    if abs(time.time() - int(event.get('timestamp', 0))) > 300:
        return 'Payload too old', 401

    # Process event['event'] and event['data']
    return 'OK', 200

Webhook Delivery, Retries and Replay Protection

  • Respond quickly. Return a 2xx status within 10 seconds. Do slow work after you have responded.
  • Retries. If your endpoint cannot be reached or times out, the delivery is attempted up to 3 times in total, waiting 10 and then 30 seconds between attempts. A response with a non-2xx status is recorded as a failure and is not retried, so do not rely on returning an error to get a redelivery.
  • Automatic disabling. After repeated consecutive failed deliveries an endpoint is switched off. A successful delivery resets the count. Re-enable the endpoint in Settings > API and Webhooks once it is fixed.
  • Timestamp tolerance. Every payload carries a Unix timestamp inside the signed body (the same value is repeated in the X-Forquotez-Timestamp header). Reject payloads older than about 5 minutes, as the samples above do. Check the value in the body: the header is not covered by the signature.
  • Replay protection is on your side. forquotez does not send a unique delivery ID, and a retried delivery is signed again with a new timestamp. Make your handler idempotent, for example by ignoring an event you have already processed for the same event and record ID.
  • Ordering. Deliveries are queued independently per endpoint and may arrive out of order.

Errors

The API uses standard HTTP status codes.

CodeMeaning
200Success
201Created (POST requests)
401Unauthorized (missing or invalid token)
403Forbidden (plan does not include API access)
404Not found
422Validation error (check the errors object in the response)
429Rate limited (check Retry-After header)
500Server error