Webhooks
Read time: 14 minutes. Who it's for: Developers receiving QuotWay events, and the store Admins or Managers who set up the endpoints.
⚠️ Webhooks require the Enterprise plan (a live trial counts). If a store moves to a lower plan, its endpoints are kept but paused - nothing is delivered until it's back on Enterprise. Uninstalling QuotWay disables every endpoint; after a reinstall, re-enable them in Settings.
What are QuotWay webhooks?
A webhook is an HTTPS POST that QuotWay sends to a URL you choose whenever something happens to
a quote - a request arrives, a proposal goes out, the buyer counters, accepts or declines, an
approval is needed, a draft order is created. Your system reacts in seconds instead of polling.
QuotWay webhooks follow the open Standard Webhooks
specification, so you can verify them with the official libraries in most languages. They're
PII-free by design: a payload names the quote and carries a small summary, never the buyer's
name, email, phone or address. When you need those, fetch the quote from the API with a key that has
read_customer_data.
Set up an endpoint in QuotWay
- In your Shopify admin, open QuotWay → Settings → Integrations → Webhooks.
- Select Add endpoint.
- Enter the Endpoint URL - an
https://URL on a public hostname (see URL rules). - Optionally add a Description (for example ERP order sync).
- Leave Send every event on (new event types are then included automatically), or turn it off and tick the events you want. Events are grouped as Intake, Negotiation, Outcome, Orders and Approvals.
- Select Add endpoint to save it, then copy the signing secret. It starts with
whsec_and is shown only once. - Select Send test to fire a
pingat the endpoint and see the HTTP status it returned.
Who can do this: Admins and Managers can add, edit, test, disable, rotate and delete endpoints. Sales reps can view them. A store can have up to 20 endpoints.
Or create an endpoint through the API
With a key that has the manage_webhooks scope:
curl https://api.quotway.com/v1/webhooks \
-H "Authorization: Bearer $QUOTWAY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f" \
-d '{
"url": "https://erp.example.com/hooks/quotway",
"events": ["quote.accepted", "quote.partially_accepted", "quote.converted"],
"description": "ERP order sync"
}'
{
"object": "webhook_endpoint",
"id": "clz9e5f6g000aqw7hk7l8m9n0",
"url": "https://erp.example.com/hooks/quotway",
"description": "ERP order sync",
"events": ["quote.accepted", "quote.converted", "quote.partially_accepted"],
"status": "active",
"source": "merchant",
"created_at": "2026-09-30T10:15:02.004Z",
"secret": "whsec_EXAMPLE_REPLACE_WITH_YOUR_ENDPOINT_SECRET"
}
events- omit it, or send[], to receive every event type. Unknown types are rejected.secret- returned only in this response. Store it now.status-active,disabled(switched off by staff) orauto_disabled(switched off by QuotWay after repeated failures, see Auto-disable).source- an optional label; leave it out (it defaults tomerchant).
GET /v1/webhooks lists every endpoint (never with secrets), GET /v1/webhooks/{id} returns one,
and DELETE /v1/webhooks/{id} removes one:
{ "object": "webhook_endpoint", "id": "clz9e5f6g000aqw7hk7l8m9n0", "deleted": true }
Editing an endpoint, sending a test, resending a delivery, rotating the secret and re-enabling an endpoint are done in the admin (Settings → Integrations → Webhooks).
What does a delivery look like?
POST /hooks/quotway HTTP/1.1
Host: erp.example.com
content-type: application/json
user-agent: QuotWay-Webhooks/1.0 (+https://www.quotway.com/docs/api)
webhook-id: clz9m1a2b0004qw7hx8y9z0ab
webhook-timestamp: 1759155121
webhook-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=
{
"type": "quote.partially_accepted",
"timestamp": "2026-09-29T14:12:00.000Z",
"api_version": "2026-10-01",
"data": {
"object": "quote",
"id": "clz8k2x9f0001qw7h3m4n5p6r",
"sequence": "48213",
"actor": "buyer",
"url": "https://api.quotway.com/v1/quotes/clz8k2x9f0001qw7h3m4n5p6r",
"event": {
"previous_status": "proposal_sent",
"new_status": "partially_accepted",
"version_number": 2
},
"quote": {
"object": "quote",
"id": "clz8k2x9f0001qw7h3m4n5p6r",
"number": "QW-1042",
"status": "partially_accepted",
"mode": "wholesale",
"currency": "USD",
"total": "2225.00",
"accepted_total": "1187.50",
"headline": "1187.50",
"headline_mode": "open",
"updated_at": "2026-09-29T14:12:00.000Z"
}
}
}
Headers
| Header | Meaning |
|---|---|
webhook-id |
The event id. The same across every retry and resend of this event, and equal to the event's id in GET /v1/events. Use it to de-duplicate. |
webhook-timestamp |
Unix seconds when this attempt was sent. Part of the signature. |
webhook-signature |
v1,<base64 signature>. During a secret rotation it holds two signatures separated by a space. |
user-agent |
QuotWay-Webhooks/1.0 (+https://www.quotway.com/docs/api) |
Body
| Field | Meaning |
|---|---|
type |
The event type. |
timestamp |
When the event happened (not when it was sent). |
api_version |
The payload version, currently 2026-10-01. |
data.object / data.id |
"quote" and the quote's id. |
data.sequence |
Ordering key, as a string. Higher is later. Compare as a big integer. |
data.actor |
Who caused the event: buyer, merchant or system. |
data.url |
The quote on the API - fetch it for full, authoritative detail. |
data.event |
Event-specific detail - see Event data. |
data.quote |
A small summary of the quote as it is when the attempt is sent (not when the event happened), or null if the quote was deleted since. Fields: id, number, status, mode, currency, total, accepted_total, headline, headline_mode, updated_at - the same meanings as on the quote object. |
Event catalog
Subscribe to all of these or any subset. The catalog only ever grows: a published event type is never renamed or repurposed. Your handler should ignore types it doesn't recognise.
Intake
| Event | Fires when |
|---|---|
quote.created |
A new quote exists: a buyer requested one (storefront, customer account quick order, or POST /v1/quotes), or staff created or duplicated one. data.event.source says which: storefront, quick_order, api, manual or duplicate. |
Negotiation
| Event | Fires when |
|---|---|
quote.proposal_sent |
A proposal version goes to the buyer - sent by staff, by an automation rule, through the API, or released after approval. |
quote.countered |
The buyer makes a counter-offer. |
quote.lines_updated |
Staff add or remove lines on a live quote. |
quote.message_created |
A message is posted on the quote's conversation by the buyer, staff, an automation follow-up, a Shopify Flow action or the API. Internal notes never fire it. |
quote.assigned |
The quote's assigned staff member changes. |
Outcome
| Event | Fires when |
|---|---|
quote.accepted |
The buyer accepts the whole proposal. |
quote.partially_accepted |
The buyer accepts some lines (declining or deferring the others). |
quote.acceptance_voided |
Staff void a buyer's acceptance, before conversion, so the buyer can act again. The quote returns to proposal_sent. |
quote.declined |
The quote is declined - by the buyer, by staff, or by an auto-decline automation rule. |
quote.expired |
The offer passes its expiry date. |
quote.expiring_soon |
Once per proposal, when it enters the store's expiry-reminder window. data.event.expires_at holds the expiry. Only fires when the store has expiry reminders switched on (a reminder period set and the reminder email enabled). |
quote.reopened |
A partially accepted or partially converted quote is reopened for review. |
quote.closed |
The quote is closed. |
Orders
| Event | Fires when |
|---|---|
quote.converted |
A Shopify draft order is created from the quote. Fires once per draft order - a quote split into several orders fires it several times. data.event carries draft_order_id, conversion_group_id and is_final (true when this conversion completed the quote). |
quote.invoice_sent |
The draft-order invoice is sent to the buyer. |
quote.order_completed |
The buyer paid and the order was placed. |
quote.order_cancelled |
The resulting order was cancelled. |
quote.order_refunded |
The resulting order was refunded. |
Approvals
| Event | Fires when |
|---|---|
quote.merchant_approval.requested |
A proposal needs your team's internal approval before it's sent. |
quote.merchant_approval.granted |
Your team's approval chain approved it. |
quote.merchant_approval.rejected |
Your team's approval chain rejected it. |
quote.buyer_approval.requested |
The buyer's own approval chain starts reviewing the proposal. |
quote.buyer_approval.granted |
The buyer's approval chain approved it. |
quote.buyer_approval.rejected |
The buyer's approval chain rejected it. The quote becomes declined, and this event fires - not quote.declined. |
Approval events report the chain's outcome, once each; individual approver steps aren't published.
There is also a ping type, sent only by Send test. You can't subscribe to it and it's never
retried - see Test pings.
Event data
data.event carries only what applies to the event:
| Field | On | Meaning |
|---|---|---|
previous_status, new_status |
status changes | The quote's status before and after. |
version_number |
proposal, counter and acceptance events | The proposal version involved. |
approval_kind |
approval events | merchant or buyer. |
source |
quote.created |
Where the quote came from (see above). |
draft_order_id, conversion_group_id, is_final |
quote.converted |
The draft order created. |
expires_at |
quote.expiring_soon |
When the offer expires. |
Verify the signature
Always verify before you trust a delivery. The signature proves the request came from QuotWay with this exact body, and the timestamp stops an old request being replayed.
How it's computed: base64( HMAC-SHA256( key, "<webhook-id>.<webhook-timestamp>.<raw body>" ) ),
where key is the base64-decoded part of your secret after whsec_. The header value is that
signature prefixed with v1,.
Two rules that trip people up:
- Verify the raw request body, byte for byte - not a re-serialised JSON object.
- Reject stale timestamps. The official libraries reject a
webhook-timestampmore than five minutes from your clock; do the same if you verify by hand.
Node.js - standardwebhooks (npm)
npm install standardwebhooks
import express from "express";
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.QUOTWAY_WEBHOOK_SECRET); // "whsec_…"
const app = express();
// Keep the body raw for this route — verification needs the exact bytes.
app.post("/hooks/quotway", express.raw({ type: "application/json" }), async (req, res) => {
let event;
try {
event = wh.verify(req.body.toString("utf8"), req.headers);
} catch {
return res.status(400).send("Invalid signature");
}
const eventId = req.headers["webhook-id"];
if (await alreadyProcessed(eventId)) return res.sendStatus(200);
await enqueueForProcessing(eventId, event); // do the real work asynchronously
res.sendStatus(200);
});
Python - standardwebhooks (pip)
pip install standardwebhooks
import os
from flask import Flask, request, abort
from standardwebhooks.webhooks import Webhook
wh = Webhook(os.environ["QUOTWAY_WEBHOOK_SECRET"]) # "whsec_…"
app = Flask(__name__)
@app.post("/hooks/quotway")
def quotway_webhook():
try:
event = wh.verify(request.get_data(), dict(request.headers))
except Exception:
abort(400)
event_id = request.headers["webhook-id"]
if already_processed(event_id):
return "", 200
enqueue_for_processing(event_id, event)
return "", 200
PHP (or any language) - verify by hand
<?php
$secret = getenv('QUOTWAY_WEBHOOK_SECRET'); // "whsec_…"
$key = base64_decode(substr($secret, strlen('whsec_')));
$id = $_SERVER['HTTP_WEBHOOK_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$header = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$body = file_get_contents('php://input'); // the RAW body
// 1. Reject stale or future timestamps (5-minute tolerance).
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300) {
http_response_code(400); exit;
}
// 2. Compute the expected signature.
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$body}", $key, true));
// 3. Compare against every signature in the header (two during a rotation).
$valid = false;
foreach (explode(' ', $header) as $candidate) {
$parts = explode(',', $candidate, 2);
if (count($parts) === 2 && $parts[0] === 'v1' && hash_equals($expected, $parts[1])) {
$valid = true;
break;
}
}
if (!$valid) { http_response_code(400); exit; }
$event = json_decode($body, true);
// De-duplicate on $id, queue the work, then:
http_response_code(200);
The same three steps work in any language: check the timestamp, compute the HMAC over
id.timestamp.body with the decoded secret, and constant-time compare against each v1, signature
in the header.
Respond quickly, process later
- Return any 2xx status within 10 seconds to acknowledge a delivery. The response body is ignored.
- Anything else - a 3xx, 4xx, 5xx, a timeout or a connection error - is a failed attempt and is retried.
- Do the real work after responding (a queue or background job), so a slow downstream system can't make QuotWay think the delivery failed.
How should I handle duplicates and ordering?
Delivery is at least once: the same event can arrive more than once (a retry after a timeout,
or a manual resend). De-duplicate on webhook-id.
Deliveries aren't guaranteed to arrive in order. Order events by data.sequence, and remember
data.quote shows the quote's state at the moment of sending - a retry an hour later shows the newer
status. For anything that must be right, fetch the quote from data.url.
The fetch-the-quote pattern:
- Verify the signature and de-duplicate on
webhook-id. - Respond
200. - In the background,
GET data.urlwith your API key (read_quotes, plusread_customer_dataif you need buyer contact details) and upsert the result.
Retries
A failed delivery is retried 7 times after the first attempt - 8 attempts in all - on this schedule:
| Retry | Delay after the previous attempt |
|---|---|
| 1 | 5 minutes |
| 2 | 30 minutes |
| 3 | 2 hours |
| 4 | 6 hours |
| 5 | 12 hours |
| 6 | 24 hours |
| 7 | 48 hours |
That's about 3.7 days end to end. Each delay has ±10% jitter, and actual timing can run up to
15 minutes later than scheduled. If your endpoint answers 429 or 503 with a Retry-After header
(up to one day), QuotWay waits at least that long before the next attempt.
After the last attempt the delivery is marked Gave up. It isn't retried again automatically -
resend it from the admin, or pick the event up from GET /v1/events.
Webhooks are normally sent within seconds of the event. During maintenance or an incident QuotWay may pause deliveries; paused deliveries are held and sent later, not dropped.
When your endpoint is down. If a delivery gets no response (a timeout or connection error), a
5xx, or a 429, QuotWay treats the endpoint as unavailable: its other queued deliveries wait
for that retry instead of each being sent into the same outage. Those held deliveries don't use up
any of their own attempts. A 4xx other than 429 is taken to be about that one payload, so the
endpoint's other deliveries still go out.
Fair delivery. Deliveries are sent round-robin across endpoints and stores. A large backlog on one endpoint is worked through a few at a time and never holds up anyone else's events.
Auto-disable
If every delivery to an endpoint has failed for 5 days in a row, QuotWay switches the endpoint to Auto-disabled and emails the store. Any successful delivery resets the clock.
While an endpoint is disabled (by QuotWay or by staff), new events aren't queued for it. Fix the
receiver, use Send test to check it (tests work on disabled endpoints), select Enable, and
then catch up on the gap from GET /v1/events.
Delivery log and resending
Select Deliveries on an endpoint to see its recent deliveries: the event, status, attempts, last HTTP status or error, and timing. Statuses read Delivered, Retrying, Failed and Gave up.
Resend puts any delivery back in line - one that gave up or failed, or one that was delivered
but your system lost. The resend has the same webhook-id, so your de-duplication still works,
and its attempt count starts again. Test pings can't be resent.
Delivery records are kept for 30 days.
Test pings
Send test sends one ping straight away and shows the result:
{
"type": "ping",
"timestamp": "2026-09-30T10:16:44.000Z",
"api_version": "2026-10-01",
"data": { "object": "webhook_endpoint", "id": "clz9e5f6g000aqw7hk7l8m9n0" }
}
A ping is signed like any delivery, goes out even if the endpoint is disabled and whichever events
it subscribes to, is never retried, and never counts toward auto-disable. Its
webhook-id is a one-off id, not an event id. Make sure your handler answers 2xx to ping.
Rotate the signing secret
Select Rotate secret on an endpoint. QuotWay shows the new secret once and, for the next
24 hours, signs every delivery with both the new and the old secret (two signatures in
webhook-signature). The official libraries accept a request that matches either, so:
- Rotate.
- Deploy the new secret to your receiver within 24 hours.
- After 24 hours the old secret stops being used.
Rotate whenever a secret may have been exposed, and when people with access to it leave.
URL rules
To protect your network and ours, an endpoint URL must:
- use
https://- plain HTTP is refused; - use a hostname, not an IP address (in any notation);
- use the default port 443, or 8443;
- contain no username or password;
- not point at
localhost, an internal-looking name (.local,.internal,.lan,.corpand similar) or a QuotWay domain; - resolve only to public IP addresses. This is checked every time QuotWay connects, so a host that resolves to a private, loopback, link-local or other reserved address is refused even if its DNS changes after you saved it.
QuotWay never follows redirects: a 3xx response counts as a failed attempt. Point the endpoint at the final URL.
Related
- Events feed - recover anything a webhook missed.
- The quote object - what you get when you fetch
data.url. - Authentication - keys for the fetch-the-quote pattern.
Still need a hand? The team is happy to help.