Developer docs menu

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 POST requests. A test-mode endpoint may use HTTP, for a tunnel.
  • Access to Developers in the console.

Create an endpoint

  1. In the console, open Developers and choose the Webhooks tab.
  2. 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.
  3. Copy the signing secret. It starts with whsec_.
  4. Press Test to send a webhook.test event 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" }
}
FieldTypeDescription
idstringThe event id, evt_…. The same across retries.
typestringThe event type, for example order.created.
createdstringWhen the event happened.
modelive | testThe mode of the widget or key that caused the event.
api_versionstringThe payload shape the endpoint is pinned to.
dataobjectThe event’s own payload.

Each delivery also carries these headers:

HeaderDescription
Quotometry-EventThe event type, so you can route without parsing the body.
Quotometry-Event-IdThe event id, evt_…. Use this for idempotency.
Quotometry-Delivery-IdThis attempt’s id. It differs between retries of the same event.
Quotometry-TimestampUnix seconds, signed together with the body.
Quotometry-Signaturev1=<hex>. During a secret rotation it holds two comma-separated values.
Quotometry-Api-VersionThe payload shape you are being sent.
User-AgentQuotometry-Webhooks/1.

Header names are case-insensitive. The code samples below read them in lower case.

Events

EventSent when
order.createdA buyer places an order. The payload carries the order, its lines, the customer and your metadata.
order.status_changedThe shop moves an order to another status.
quote.createdA quote is saved.
quote.sentThe shop sends a quote to a buyer.
quote.expiringA sent quote’s accept link runs out in three days. Sent once per quote.
quote.declinedThe shop declines a quote.
rfq.createdA buyer sends a request for quote.
review.requestedAn order needs a person at the shop to confirm its price.
model.analysedA file’s analysis has finished.
model.analysis_failedA file could not be analysed.
usage.threshold_reachedQuote requests reach one of your quota-alert thresholds (50, 80 or 100 % by default).
config.publishedA configuration version is published.
webhook.testYou 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:

  1. Hash the raw bytes, before any JSON parsing. Re-serialising changes whitespace and key order, and the signature will never match.
  2. Compare in constant time. == on a string leaks how much of the digest you got right.
  3. 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 happenedHow 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