Webhooks

Know the moment a client accepts

Instead of polling the API to find out what changed, let us tell you. When a proposal is sent, opened, accepted or rejected, we post a signed JSON payload to a URL you control — usually within a second.

Available on the Business plan.

Adding an endpoint

In CueQuote, go to Settings → Webhooks and choose Add endpoint. Paste the https URL that should receive the events and tick the ones you want.

You will be shown a signing secret starting whsec_. Copy it then — it is shown once. It is what lets you prove a request came from us rather than from anyone who learned your URL.

Events

EventFires when
proposal.sentYou send a proposal to a client.
proposal.viewedThe client opens the share link for the first time.
proposal.acceptedThe client accepts. This is the one most people wire to their CRM.
proposal.rejectedThe client declines.

Events fire wherever the change happened — in the app, through the API, or from a client clicking accept on a share link.

The payload

{
  "event": "proposal.accepted",
  "created_at": "2026-08-09T14:02:11.482Z",
  "data": {
    "proposal": {
      "id": "4b888edb-d71a-4aa1-87fb-26ba1d3fc103",
      "title": "Warsaw Tech Summit 2026",
      "status": "accepted",
      "currency": "PLN",
      "subtotal": 40010,
      "total": 40010,
      "event_date": "2026-11-03",
      "venue_name": "Warsaw",
      "attendee_count": 200,
      "created_at": "2026-08-01T09:14:22.010Z",
      "share_url": "https://app.cuequote.com/share/961419b1-...",
      "app_url": "https://app.cuequote.com/proposals/4b888edb-..."
    },
    "client": {
      "id": "c2f0a1e8-...",
      "name": "Acme Events",
      "contact_name": "Marta Nowak",
      "contact_email": "ops@acme.example"
    }
  }
}

client is null if the proposal has no client attached. Line items are not included — fetch the proposal from the API if you need them.

Verifying the signature

Every request carries an X-CueQuote-Signature header:

X-CueQuote-Signature: t=1786296938,v1=5a1f...c93b

v1 is an HMAC-SHA256 of {timestamp}.{raw body}, keyed with your signing secret. Compute it over the raw body — parsing and re-serialising the JSON changes the bytes and the signature will not match.

// Node.js / Express
import crypto from 'node:crypto'

app.post('/hooks/cuequote',
  express.raw({ type: 'application/json' }),   // raw body, not express.json()
  (req, res) => {
    const header = req.get('X-CueQuote-Signature') || ''
    const { t, v1 } = Object.fromEntries(
      header.split(',').map(p => p.split('='))
    )

    // Reject anything older than five minutes, so a captured request
    // cannot be replayed at you later.
    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400)

    const expected = crypto
      .createHmac('sha256', process.env.CUEQUOTE_WEBHOOK_SECRET)
      .update(`${t}.${req.body}`)
      .digest('hex')

    // timingSafeEqual, not ===, so the comparison does not leak the
    // signature one byte at a time.
    const ok = v1.length === expected.length && crypto.timingSafeEqual(
      Buffer.from(v1), Buffer.from(expected)
    )
    if (!ok) return res.sendStatus(401)

    const { event, data } = JSON.parse(req.body)
    // ... your logic
    res.sendStatus(200)
  })
# Python / Flask
import hmac, hashlib, time
from flask import request, abort

@app.post("/hooks/cuequote")
def cuequote():
    parts = dict(p.split("=") for p in request.headers.get("X-CueQuote-Signature", "").split(","))
    if abs(time.time() - int(parts["t"])) > 300:
        abort(400)

    expected = hmac.new(
        SECRET.encode(),
        f"{parts['t']}.{request.get_data(as_text=True)}".encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(parts["v1"], expected):
        abort(401)

    payload = request.get_json()
    # ... your logic
    return "", 200

Responding

Return any 2xx status. Anything else is recorded as a failure and shown in your Settings so you can see the endpoint is broken.

Answer quickly and do the work afterwards — we give up after 10 seconds. If your handler is slow, acknowledge first and queue the job.

RuleWhy
https onlyA signed payload sent in plaintext still exposes your client's name, the event and the value to anyone on the network path.
Public addresses onlyEndpoints resolving to private or internal ranges are refused, both when you add them and again at delivery time.
No redirectsWe deliver to the URL you gave us and do not follow a 301 or 302 elsewhere.
Expect repeatsTreat handlers as idempotent — key off the proposal id and event rather than assuming exactly one delivery.

Troubleshooting

You seeFix
Signature never matchesYou are almost certainly hashing a re-serialised body. Capture the raw bytes before any JSON middleware touches them.
“Failing” badge in SettingsYour endpoint returned a non-2xx or timed out in the last 7 days. Check your own logs first.
Nothing arrives at allConfirm the event is ticked on the endpoint, and that the status actually changed — re-sending an already-sent proposal does not fire proposal.sent twice.
“The endpoint must be an https URL”http is refused. Use https, including in staging.
Lost the signing secretIt cannot be shown again. Delete the endpoint and add it back to get a new one.

Also available

The REST API to create proposals, the website quote form for your visitors, and the MCP server for AI assistants. Building something we have not covered? Tell us at hello@cuequote.com.