Skip to main content

Webhooks

Get real-time push notifications when things happen in your Quotery account. No polling, no cron jobs. Just signed HTTP callbacks delivered straight to your server.

What are webhooks?

A webhook is a push notification that Quotery sends to your server whenever something important happens. A quote gets accepted. A delivery note is marked delivered. A return note is created. Instead of you asking "anything new?" over and over (that's polling, and it's wasteful), we call you the moment an event fires.

You tell us a URL and which event types you care about. When one of those events happens in your account, we POST a signed JSON payload to your URL. You verify the signature to make sure it really came from us, process the data, and return a 200. That's it.

Webhooks are the backbone of real-time integrations. Use them to push orders into your ERP, sync inventory with an external system, fire Slack notifications when a quote closes, or build an audit log of everything happening in your tenant. You pick the events, you own the receiver.

Available event types

Each event type represents a specific business action inside Quotery. You subscribe to the ones relevant to your integration. Pick a few or all seven.

  • quote.closedFires when a quote is closed (finalized and sent to the client). The payload includes the quote details, line items, and totals at the time of closing.
  • quote.acceptedFires when a client accepts a quote via the customer portal. This is the go-to event for kicking off fulfillment: create an order in your ERP, notify your warehouse, or generate a sales invoice.
  • quote.cancelledFires when a quote is cancelled. Use this to unwind downstream processes: release reserved inventory, cancel a draft order, or update your CRM deal stage.
  • delivery_note.createdFires when a delivery note is created. If you're tracking outbound shipments in an external system, this event tells you a new delivery is being prepared.
  • delivery_note.marked_deliveredFires when a delivery note is marked as delivered. This confirms that goods have reached the client. Use it to decrement inventory, close a fulfillment task, or trigger an invoice.
  • stock_receipt.completedFires when a stock receipt is completed. Inbound goods are now in your inventory. Sync this with your warehouse management system or update stock levels in an external catalog.
  • return_note.createdFires when a return note is created. A client is sending something back. You'll want to adjust inventory, trigger a refund workflow, or log the return in your order management system.

Creating a webhook

Setting up a webhook takes about a minute. You'll need admin access to your Quotery account. Webhook management is an administrative function.

  1. Pick a name and destination URL
    Give your webhook a human-readable name, like "Production ERP Integration," so your team knows what it does. The destination URL must be an HTTPS endpoint that accepts POST requests with a JSON body. This is your server, or a service like Zapier, Make, or a Slack incoming webhook.
  2. Choose your event types
    Pick the events you want to subscribe to. You can select a handful (say, just quote.accepted and quote.closed) or all seven. Only the events you check will trigger deliveries to your URL. You can update this list later without creating a new webhook.
  3. Create it via the API
    Webhooks are managed through the REST API at /api/v1/webhooks/. You'll POST a JSON body with your name, URL, and events list. The response includes a signing_secret. Save this immediately. It's shown once and never again. If you lose it, you'll need to create a new webhook.
  4. Store the signing secret securely
    Treat the signing secret like a password. Store it as an environment variable on your server (QUOTERY_WEBHOOK_SECRET is a good name). You'll use it to verify every incoming delivery. Anyone with access to this secret can forge webhook payloads.
  5. Verify deliveries are arriving
    Trigger a real event in your Quotery account (close a quote, mark a delivery note as delivered) and check that your endpoint receives the POST request. You can inspect the webhook's delivery health via the GET endpoint anytime: last success timestamp and failure count. Green means it's working.

The signing secret is generated for you automatically when you create the webhook. You don't pick it. You receive it. And you only see it once, right after creation. Copy it somewhere safe before you close that response tab.

You must be logged in as an admin user to create or manage webhooks. If you're not an admin, reach out to someone on your team who is.

Verifying signatures

Every webhook delivery includes a cryptographic signature so you can be certain the payload came from Quotery and hasn't been tampered with. Verifying it is required. Never process a webhook payload without checking the signature first.

We sign the raw request body with HMAC-SHA256 using your webhook's unique signing secret. The signature is sent in the X-Quotery-Signature header in a format compatible with Stripe's webhook signature scheme. If you've worked with Stripe webhooks before, this will feel familiar.

Signature format

The X-Quotery-Signature header looks like this:

t=1714789200,v1=a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
t
The Unix timestamp (in seconds) when the signature was generated. Your verification code should reject any delivery with a timestamp more than 5 minutes old. This prevents replay attacks.
v1
The HMAC-SHA256 hex digest of the request body, computed with your signing secret.

Python

import hmac, hashlib, time

def verify_signature(body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in signature_header.split(","))
    timestamp = int(parts["t"])
    if abs(time.time() - timestamp) > tolerance:
        return False  # outside tolerance window
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)

The tolerance parameter (default 300 seconds = 5 minutes) rejects deliveries that are too old. This is your replay-attack guard. Adjust it if your clocks are skewed, but don't set it much higher than 5 minutes.

Node.js

const crypto = require("crypto");

function verifySignature(rawBody, signatureHeader, secret, tolerance = 300) {
  // Parse "t=1714789200,v1=abc123..." into { t, v1 }
  const parts = {};
  signatureHeader.split(",").forEach(p => {
    const idx = p.indexOf("=");
    parts[p.slice(0, idx)] = p.slice(idx + 1);
  });

  const timestamp = parseInt(parts.t, 10);
  const now = Math.floor(Date.now() / 1000);

  // Reject if outside tolerance window (5 minutes)
  if (Math.abs(now - timestamp) > tolerance) {
    return false;
  }

  // Compute the expected signature
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  // Constant-time comparison prevents timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(parts.v1),
    Buffer.from(expected)
  );
}

The signature check uses timingSafeEqual instead of a plain string comparison. Regular equality operators short-circuit, which leaks timing information about how much of the signature matched. Always use a constant-time comparison for HMAC verification.

Delivery headers

Every POST delivery includes these four headers. Use them for signature verification, idempotency, and routing decisions.

HeaderValue
Content-Typeapplication/json
X-Quotery-Webhook-IdUUID (e.g. 550e8400-e29b-41d4-a716-446655440000)
X-Quotery-EventDot-delimited event type (e.g. quote.closed)
X-Quotery-Signaturet=1714789200,v1=abc123...

Content-TypeThe request body is always JSON. Your endpoint can safely assume this and parse accordingly.

X-Quotery-Webhook-IdA unique identifier for this specific delivery attempt. Use it as an idempotency key. If a delivery is retried, it carries the same ID, so you can deduplicate by checking whether you've already processed this webhook ID.

X-Quotery-EventTells you exactly which event fired. Your endpoint can switch on this value to route different events to different handlers.

X-Quotery-SignatureThe HMAC-SHA256 signature. Parse it, verify it, and only process the payload if it's valid.

Retry behavior

If your endpoint is down or returns a non-2xx status, Quotery retries the delivery automatically. You don't need to configure anything. The retry schedule is built in.

Retry schedule

Failed deliveries are retried up to 6 times using exponential backoff. Here's the timeline from the initial delivery attempt:

  • Attempt 1: 1 minute after the first failure
  • Attempt 2: 5 minutes after the first failure
  • Attempt 3: 15 minutes after the first failure
  • Attempt 4: 1 hour after the first failure
  • Attempt 5: 4 hours after the first failure
  • Attempt 6: 12 hours after the first failure

The full retry window spans about 12 hours. After 6 failed attempts, the delivery is dropped permanently.

Tracking delivery health

Every webhook tracks two health indicators: the timestamp of the last successful delivery, and a consecutive failure count. A successful delivery resets the failure count to zero. A string of failures increments it. You can check both at any time via the API. No logs to dig through, no alerts to configure. If you see a climbing failure count, your endpoint is likely down or rejecting deliveries.

Because retries reuse the same X-Quotery-Webhook-Id, your endpoint can safely handle duplicate deliveries. Keep a small cache of recently processed webhook IDs (a few hours is plenty given the 12-hour retry window), and skip any delivery whose ID you've already seen.

Payload structure

Every webhook delivery carries the same outer envelope. The event-specific details live inside the data object.

{
  "event_type": "quote.closed",
  "tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "timestamp": "2026-05-03T10:00:00Z",
  "data": {
    // Event-specific payload — varies by event_type
  }
}
event_type
The dot-delimited event type from the list above (e.g. quote.closed). Use this to route the payload to the right handler in your integration.
tenant_id
The UUID of your Quotery tenant. If you're building an integration that serves multiple tenants, use this to scope processing to the correct account.
timestamp
An ISO 8601 UTC timestamp of when the event fired. Useful for ordering events or detecting late deliveries.
data
The event-specific payload. For a quote event, this might include the quote ID, client details, line items, and totals. For a delivery note event, it would include the delivery note ID, items, and status. The exact shape depends on the event type and the entity's serialized representation at the time the event fired.

Testing your webhook

You don't need a staging environment to test webhooks. You can trigger real events in your Quotery account and watch them land on your endpoint in real time.

  1. Use webhook.site for initial testing
    webhook.site gives you a temporary URL that logs every incoming request. Create a webhook pointed at a webhook.site URL first. This lets you inspect the exact headers and payload shape before you write any receiver code. Once you're happy with the format, switch the URL to your real endpoint.
  2. Trigger a real event
    Perform the action in Quotery that corresponds to your subscribed event: close a quote, mark a delivery note as delivered, complete a stock receipt. The webhook fires automatically the moment the event is saved. No extra button to push.
  3. Check delivery health
    Send a GET request to /api/v1/webhooks/ to see last_success_at and failure_count for your webhook. If last_success_at updated and failure_count is zero, your endpoint received and acknowledged the delivery. If failure_count is climbing, check that your endpoint is reachable, returning 2xx, and responding within 10 seconds.
  4. Iterate on your handler
    Use the real payload data to build out your integration logic. Parse the event_type to route to the right handler, extract the fields you need from data, and map them into your downstream system. The headers and signature verification pattern stay the same regardless of which event type you're handling.

Code examples

Ready to wire it up? Here are the two most common tasks: creating a webhook and receiving deliveries, ready to copy and paste.

Creating a webhook with curl

This curl command creates a webhook that listens for quote.accepted, quote.closed, and delivery_note.marked_delivered events. You'll need your session cookie from an authenticated admin browser session.

curl -X POST https://app.quotery.io/api/v1/webhooks/ \
  -H "Content-Type: application/json" \
  -H "Cookie: sessionid=<your-admin-session-id>" \
  -d '{
    "name": "My Integration",
    "url": "https://myapp.example.com/webhooks/quotery",
    "events": ["quote.accepted", "quote.closed", "delivery_note.marked_delivered"]
  }'

The 201 response includes the webhook ID and, critically, the signing_secret. Copy that secret now. You won't see it again.

Node.js receiver endpoint

Here's a minimal Express endpoint that verifies the signature, checks the timestamp tolerance, and deduplicates by webhook ID. Drop this into your existing Node app or use it as a starting point.

const express = require("express");
const crypto = require("crypto");

const app = express();
const SECRET = process.env.QUOTERY_WEBHOOK_SECRET;
const TOLERANCE = 300; // 5 minutes in seconds

// In-memory idempotency store — use Redis or a DB in production
const processedIds = new Set();

app.post("/webhooks/quotery", express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; }
}), (req, res) => {
  // 1. Parse the signature header
  const header = req.headers["x-quotery-signature"];
  if (!header) return res.status(400).send("Missing signature");

  const parts = {};
  header.split(",").forEach(p => {
    const idx = p.indexOf("=");
    parts[p.slice(0, idx)] = p.slice(idx + 1);
  });

  // 2. Reject if timestamp is outside tolerance
  const timestamp = parseInt(parts.t, 10);
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE) {
    return res.status(400).send("Timestamp outside tolerance");
  }

  // 3. Verify the HMAC signature
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(req.rawBody)
    .digest("hex");

  const valid = crypto.timingSafeEqual(
    Buffer.from(parts.v1),
    Buffer.from(expected)
  );
  if (!valid) return res.status(400).send("Invalid signature");

  // 4. Deduplicate by webhook ID
  const deliveryId = req.headers["x-quotery-webhook-id"];
  if (processedIds.has(deliveryId)) {
    return res.sendStatus(200); // Already processed — acknowledge silently
  }
  processedIds.add(deliveryId);

  // 5. Process the event
  const eventType = req.headers["x-quotery-event"];
  const payload = req.body;
  console.log(`Received ${eventType}`, payload);

  switch (eventType) {
    case "quote.accepted":
      // Create an order in your ERP
      break;
    case "quote.closed":
      // Update your CRM deal stage
      break;
    case "delivery_note.marked_delivered":
      // Decrement inventory, trigger invoice
      break;
    default:
      console.log(`Unhandled event type: ${eventType}`);
  }

  res.sendStatus(200);
});

app.listen(process.env.PORT || 3000, () => {
  console.log("Webhook receiver listening");
});

The idempotency check at step 4 is important. Quotery retries deliveries up to 6 times, and each retry carries the same X-Quotery-Webhook-Id. Without deduplication, you'd process the same event multiple times: duplicate orders, double-counted inventory changes, and so on.