Aller au contenu

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

  1. In your Shopify admin, open QuotWay → Settings → Integrations → Webhooks.
  2. Select Add endpoint.
  3. Enter the Endpoint URL - an https:// URL on a public hostname (see URL rules).
  4. Optionally add a Description (for example ERP order sync).
  5. 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.
  6. Select Add endpoint to save it, then copy the signing secret. It starts with whsec_ and is shown only once.
  7. Select Send test to fire a ping at 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) or auto_disabled (switched off by QuotWay after repeated failures, see Auto-disable).
  • source - an optional label; leave it out (it defaults to merchant).

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-timestamp more 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:

  1. Verify the signature and de-duplicate on webhook-id.
  2. Respond 200.
  3. In the background, GET data.url with your API key (read_quotes, plus read_customer_data if 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:

  1. Rotate.
  2. Deploy the new secret to your receiver within 24 hours.
  3. 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, .corp and 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.

Besoin d'un coup de main ? L'équipe est là pour vous aider.

Nous souhaitons déposer des cookies de mesure d'audience pour comprendre comment le site est utilisé. Ils ne sont pas nécessaires : refuser ne change rien au fonctionnement du site, et vous pouvez revenir sur votre choix à tout moment depuis notre page de confidentialité.