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://www.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
| Event | Description |
|---|---|
| quote.created | A quote was saved from the runner |
| quote.sent | A quote was marked as sent or sent for signing |
| quote.viewed | A prospect viewed the quote for the first time |
| quote.signed | All required signatures were completed |
| quote.declined | A prospect declined the quote |
| quote.expired | A quote passed its expiration date |
| quote.voided | A manager or admin voided the quote |
| signature.sent | A signature request was sent to signers |
| signature.completed | All signers completed, signed PDF compiled |
| deal.created | A deal was created in the CRM |
| deal.stage_changed | A deal moved to a different pipeline stage |
| contact.created | A 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-Signature | HMAC-SHA256 of the request body using your endpoint's signing secret |
| X-Forquotez-Timestamp | Unix timestamp when the payload was generated |
| X-Forquotez-Event | The event name (e.g., quote.signed) |
| Content-Type | application/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
2xxstatus 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
timestampinside the signed body (the same value is repeated in theX-Forquotez-Timestampheader). 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
eventand record ID. - Ordering. Deliveries are queued independently per endpoint and may arrive out of order.
Errors
The API uses standard HTTP status codes.
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Created (POST requests) |
| 401 | Unauthorized (missing or invalid token) |
| 403 | Forbidden (plan does not include API access) |
| 404 | Not found |
| 422 | Validation error (check the errors object in the response) |
| 429 | Rate limited (check Retry-After header) |
| 500 | Server error |