Webhooks
Receive signed Quotometry events at your own endpoint: the envelope, the event catalogue, signature checks in six languages, retries and alerts.
This guide explains how to receive Quotometry events at an endpoint you control, prove that each delivery came from Quotometry, and handle retries safely. When you finish, your system hears about every new order, quote and request for quote within seconds, and loses nothing when it is briefly down.
Before you begin
- A plan that includes webhooks (the number of endpoints you may create is on your plan).
- A public HTTPS URL that accepts
POSTrequests. A test-mode endpoint may use HTTP, for a tunnel. - Access to Developers in the console.
Create an endpoint
- In the console, open Developers and choose the Webhooks tab.
- Enter the endpoint URL, choose a mode (live or test) and select the events you want. Leave the event list empty to receive every event.
- Copy the signing secret. It starts with
whsec_. - Press Test to send a
webhook.testevent to the new endpoint.
A live endpoint receives only live events, and a test endpoint only test events.
The envelope
Every delivery is a POST with a JSON body:
{
"id": "evt_01j9x…",
"type": "order.created",
"created": "2026-09-23T09:14:22Z",
"mode": "live",
"api_version": "2026-01",
"data": { "…": "the event's own payload" }
}
| Field | Type | Description |
|---|---|---|
id | string | The event id, evt_…. The same across retries. |
type | string | The event type, for example order.created. |
created | string | When the event happened. |
mode | live | test | The mode of the widget or key that caused the event. |
api_version | string | The payload shape the endpoint is pinned to. |
data | object | The event’s own payload. |
Each delivery also carries these headers:
| Header | Description |
|---|---|
Quotometry-Event | The event type, so you can route without parsing the body. |
Quotometry-Event-Id | The event id, evt_…. Use this for idempotency. |
Quotometry-Delivery-Id | This attempt’s id. It differs between retries of the same event. |
Quotometry-Timestamp | Unix seconds, signed together with the body. |
Quotometry-Signature | v1=<hex>. During a secret rotation it holds two comma-separated values. |
Quotometry-Api-Version | The payload shape you are being sent. |
User-Agent | Quotometry-Webhooks/1. |
Header names are case-insensitive. The code samples below read them in lower case.
Events
| Event | Sent when |
|---|---|
order.created | A buyer places an order. The payload carries the order, its lines, the customer and your metadata. |
order.status_changed | The shop moves an order to another status. |
quote.created | A quote is saved. |
quote.sent | The shop sends a quote to a buyer. |
quote.expiring | A sent quote’s accept link runs out in three days. Sent once per quote. |
quote.declined | The shop declines a quote. |
rfq.created | A buyer sends a request for quote. |
review.requested | An order needs a person at the shop to confirm its price. |
model.analysed | A file’s analysis has finished. |
model.analysis_failed | A file could not be analysed. |
usage.threshold_reached | Quote requests reach one of your quota-alert thresholds (50, 80 or 100 % by default). |
config.published | A configuration version is published. |
webhook.test | You press Test on an endpoint. Sent only to that endpoint. |
model.uploaded and quote.shared are recorded but never delivered to a webhook. Billing and account notices
(invoice.issued, payment.received, subscription.changed, …) are merchant events: they can reach your
endpoint, but never a buyer.
Verify the signature
The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex-encoded and prefixed with v1=. Three rules
decide whether you have done it correctly:
- Hash the raw bytes, before any JSON parsing. Re-serialising changes whitespace and key order, and the signature will never match.
- Compare in constant time.
==on a string leaks how much of the digest you got right. - Reject old timestamps. Five minutes is a reasonable window; without one, a captured delivery can be replayed against you indefinitely.
Node.js
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody, headers, secret) {
const timestamp = Number(headers['quotometry-timestamp']);
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = 'v1=' + createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
const given = String(headers['quotometry-signature'] ?? '').split(',');
return given.some((candidate) => {
const a = Buffer.from(candidate.trim());
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
});
}
// Express: app.post('/quotometry', express.raw({ type: 'application/json' }), …)
Python
import hashlib, hmac, time
def verify(raw_body: bytes, headers, secret: str) -> bool:
try:
timestamp = int(headers["quotometry-timestamp"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > 300:
return False
expected = "v1=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
given = headers.get("quotometry-signature", "").split(",")
return any(hmac.compare_digest(candidate.strip(), expected) for candidate in given)
PHP
// $headers with lower-case names, for example array_change_key_case(getallheaders()).
function quotometry_verify(string $rawBody, array $headers, string $secret): bool {
$timestamp = (int) ($headers['quotometry-timestamp'] ?? 0);
if ($timestamp === 0 || abs(time() - $timestamp) > 300) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach (explode(',', $headers['quotometry-signature'] ?? '') as $candidate) {
if (hash_equals($expected, trim($candidate))) {
return true;
}
}
return false;
}
Ruby
require 'openssl'
def quotometry_verify(raw_body, headers, secret)
timestamp = headers['quotometry-timestamp'].to_i
return false if timestamp.zero? || (Time.now.to_i - timestamp).abs > 300
expected = 'v1=' + OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
(headers['quotometry-signature'] || '').split(',').any? do |candidate|
OpenSSL.secure_compare(candidate.strip, expected)
end
end
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func Verify(rawBody []byte, headers http.Header, secret string) bool {
timestamp, err := strconv.ParseInt(headers.Get("quotometry-timestamp"), 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-timestamp)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", timestamp)
mac.Write(rawBody)
expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
for _, candidate := range strings.Split(headers.Get("quotometry-signature"), ",") {
if hmac.Equal([]byte(strings.TrimSpace(candidate)), []byte(expected)) {
return true
}
}
return false
}
C#
public static bool Verify(byte[] rawBody, IHeaderDictionary headers, string secret)
{
if (!long.TryParse(headers["quotometry-timestamp"], out var timestamp)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var prefix = Encoding.UTF8.GetBytes($"{timestamp}.");
var signed = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);
var expected = "v1=" + Convert.ToHexString(hmac.ComputeHash(signed)).ToLowerInvariant();
foreach (var candidate in headers["quotometry-signature"].ToString().Split(','))
{
if (CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(candidate.Trim()), Encoding.UTF8.GetBytes(expected)))
{
return true;
}
}
return false;
}
Respond quickly
A delivery succeeds when your endpoint answers with any 2xx status within 10 seconds. Acknowledge first and
do the work afterwards: if your handler does something slow, it holds the delivery attempt open and risks a
timeout, which counts as a failure.
Retries
Any response outside 2xx, or no response within 10 seconds, is a failed attempt. Quotometry makes up to ten
attempts in total: the first, then nine retries after these delays:
1 min → 5 min → 30 min → 2 h → 6 h → 12 h → 24 h → 24 h → 24 h
That is about three days. After the tenth attempt the delivery is marked failed and stays in the endpoint’s
delivery log, where you can replay it once you have fixed your end.
An endpoint that has been failing continuously for seven days is switched off automatically: an endpoint nobody is watching should not be retried forever. Switching it back on in the console clears the failure streak, so the next problem is reported from the beginning.
When we e-mail you
You do not have to watch the delivery log. We e-mail the shop’s notification address, and every team member subscribed to Callback problems under Settings → Team, at four moments:
| What happened | How often we tell you |
|---|---|
| A delivery has used up its whole retry schedule. | Once per delivery. |
| An endpoint has been failing for three days. | Once per failure streak, four days before it is switched off. |
| An endpoint has been switched off. | Once per failure streak. |
| Some deliveries did not arrive today. | Once a day, per shop. |
The messages contain no payload and no signing secret: only what was being sent, to which endpoint, and what came back.
Idempotency and ordering
Delivery is at least once. A retry is the same event, with the same Quotometry-Event-Id and a new
Quotometry-Delivery-Id: store the event id and ignore one you have already processed.
Events can also arrive out of order. Treat each one as a statement about the world rather than a step in a sequence, and reconcile against the API when the order matters.
Address checks and local testing
An endpoint must resolve to a public address. Quotometry refuses to call private, loopback, link-local and cloud
metadata addresses, does not follow redirects, and connects only to the address it checked. You therefore cannot
point an endpoint at localhost. Use a tunnel such as ngrok or cloudflared with a test-mode endpoint, which
may use plain HTTP.
Next steps
- Cart recipes: convert a quote into an order from your own back end.
- Versioning and changelog: how a pinned API version protects your endpoint.
- Errors: the codes that appear in event payloads.
- Orders and notifications: the same alerts, seen from the console.