---
title: "Webhooks"
description: "Signed webhooks for 25 QuotWay quote events: payloads, Standard Webhooks signatures, retries, auto-disable and the events feed. Enterprise plan."
url: "https://www.quotway.com/de/docs/api/webhooks"
type: "documentation"
category: "api"
updated: "2026-09-30"
locale: "de"
source: "QuotWay - B2B Quote & Negotiation App for Shopify"
---

# 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](https://www.standardwebhooks.com)
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](#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:

```bash
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"
  }'
```

```json
{
  "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](#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:

```json
{ "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?

```http
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=
```

```json
{
  "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`](/docs/api/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](#event-catalog). |
| `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](#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](/docs/api/quote-object#totals). |

## 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](#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](/docs/api/quote-object#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)

```bash
npm install standardwebhooks
```

```js
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)

```bash
pip install standardwebhooks
```

```python
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
<?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`](/docs/api/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`](/docs/api/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:

```json
{
  "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.

## Related

- [Events feed](/docs/api/events) - recover anything a webhook missed.
- [The quote object](/docs/api/quote-object) - what you get when you fetch `data.url`.
- [Authentication](/docs/api/authentication) - keys for the fetch-the-quote pattern.
