Webhooks

Webhooks

Subscribe to Cooledge events and receive signed HTTP deliveries. Covers managing subscriptions in the portal, the delivery request and its headers, verifying the X-Cooledge-Signature HMAC in Node, Python and PHP, deduping on the event id, retries with dead-lettering and replaying a delivery.

Updated 17/06/2026

Webhooks

Webhooks let Cooledge push events to you instead of you polling the API. When something happens in your business, a quote is sent, a booking is created, an invoice is paid, Cooledge makes an HTTP POST to a URL you control with the event as JSON. You subscribe once and receive the events you asked for as they happen.

Each delivery is signed so you can prove it came from Cooledge, carries a stable event id so you can dedupe repeats and is retried automatically if your endpoint is down. This guide covers all of that: how to set up a subscription, what a delivery looks like, how to verify the signature in three languages and how to handle retries and replays.

Available on the Growth and Scale plans. Webhooks are part of Cooledge's API and integrations access, which Starter plans do not include. Upgrade to Growth or Scale to receive events.

Managing subscriptions

You manage webhook subscriptions with the API, or in the Cooledge portal under Settings → Integrations → Webhooks. The API endpoints all require the webhooks:manage scope.

  • Create a subscription. POST /v1/webhooks/subscriptions with the url you want deliveries sent to (an https endpoint you control) and the events you care about. Subscribe only to what you act on so you are not processing traffic you will throw away. The response includes the signing secret, shown this one time, so store it now.
  • List and inspect. GET /v1/webhooks/subscriptions returns your subscriptions, and GET /v1/webhooks/subscriptions/{id} returns one. Reads never include the secret.
  • Update. PATCH /v1/webhooks/subscriptions/{id} to change the url or events, or to set active to false to pause deliveries.
  • Delete. DELETE /v1/webhooks/subscriptions/{id} removes the subscription so it stops receiving events and no longer appears in your list. Past deliveries keep their reference, so their history is still available.
  • Rotate the secret. POST /v1/webhooks/subscriptions/{id}/rotate-secret returns a new signing secret and invalidates the old one straight away. Use it if a secret is ever exposed.

The url must be an https endpoint on the public internet. Requests to private or loopback addresses are rejected. A business can have up to 20 active subscriptions at once.

When you create or rotate, the signing secret is what you use to verify the signature on every delivery, so store it the way you would any other credential. See Verifying the signature below.

The delivery request

Every delivery is an HTTP POST to your subscription URL. The content type is application/json and the body is the event: the shared envelope plus the fields specific to that event type. The full payload shape for each event lives on its own page, linked from the events index.

A delivery carries these headers:

HeaderWhat it is
X-Cooledge-SignatureThe HMAC signature of this delivery, in the form t=<unix_seconds>,v1=<hex>. Verify it before you trust the body.
X-Cooledge-Event-IdThe event's stable id. It is the same across every redelivery and replay of this event, so use it as your dedupe key.
X-Cooledge-Delivery-IdThe id of this individual delivery attempt. It changes on every retry and every replay. Use it to correlate with the portal delivery log and when you contact support.

The body looks like this (the example is a quote.accepted event; other event types carry different fields):

{
  "version": "v1",
  "event_type": "quote.accepted",
  "event_id": "3b1c0e2a-7d44-4f0a-9b2e-1c8a2f5d9e07",
  "business_id": "a7f3c9d1-2e84-4b6f-8c01-5d9e2a1f7b30",
  "occurred_at": "2026-06-17T03:21:44.512Z",
  "quote_id": "7c4e9f12-3a86-4d51-b0e7-2f1c8a5d6b94",
  "customer_id": "e2a90f73-5c14-4b8d-9f6a-3d7b1e0c8a25",
  "quote_reference": "Q-1042",
  "accepted_total": 4850.00,
  "job_number": "J-2031"
}

The event_id in the body matches the X-Cooledge-Event-Id header, so you can dedupe on either. New keys may be added to a payload over time, so ignore fields you do not recognise rather than failing on them.

Verifying the signature

The signature proves the delivery came from Cooledge and that the body was not changed in transit. Verify it on every delivery before you act on the body.

The scheme

The X-Cooledge-Signature header is two comma-separated key=value pairs:

X-Cooledge-Signature: t=1718598104,v1=5d41402abc4b2a76b9719d911017c592...
  • t is the unix timestamp in seconds at which Cooledge signed the delivery.
  • v1 is the signature: HMAC_SHA256(secret, signed_string) rendered as lowercase hex.

The signed string is the timestamp, then a literal . (a single period), then the exact raw request body:

signed_string = "<t>" + "." + "<raw request body>"

To verify a delivery:

  1. Read the raw request body exactly as received. Do not parse the JSON and re-serialize it, because re-serializing changes whitespace and key order and so changes the bytes, which breaks the signature. Capture the raw body before any JSON middleware touches it.
  2. Parse the header into t and v1 by splitting on the comma, then on the first = in each part.
  3. Recompute HMAC_SHA256(secret, t + "." + rawBody) as lowercase hex, using the subscription's signing secret.
  4. Compare your computed value to v1 with a constant-time comparison, never ==. A constant-time compare does not leak how much of the signature matched through its timing.
  5. Check freshness. Reject the delivery if t is more than 300 seconds (5 minutes) from your current time. This is Cooledge's own tolerance and it bounds how long a captured delivery could be replayed against you.

Node.js

const crypto = require('crypto')

// rawBody is the exact bytes of the request body, as a string or Buffer.
// In Express, capture it with express.raw({ type: 'application/json' }).
function verify(rawBody, signatureHeader, secret) {
  // 1. Parse the header into t and v1.
  const parts = {}
  for (const part of signatureHeader.split(',')) {
    const i = part.indexOf('=')
    parts[part.slice(0, i).trim()] = part.slice(i + 1).trim()
  }
  const t = parts.t
  const v1 = parts.v1
  if (!t || !v1) return false

  // 2. Reject if the timestamp is more than 300 seconds from now.
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - Number(t)) > 300) return false

  // 3. Recompute the HMAC over "<t>.<rawBody>" as lowercase hex.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex')

  // 4. Constant-time compare. Both buffers must be the same length.
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(v1, 'hex')
  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}

Python

import hashlib
import hmac
import time


def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
    # 1. Parse the header into t and v1.
    parts = {}
    for part in signature_header.split(","):
        key, _, value = part.partition("=")
        parts[key.strip()] = value.strip()
    t = parts.get("t")
    v1 = parts.get("v1")
    if not t or not v1:
        return False

    # 2. Reject if the timestamp is more than 300 seconds from now.
    if abs(int(time.time()) - int(t)) > 300:
        return False

    # 3. Recompute the HMAC over "<t>.<raw_body>" as lowercase hex.
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    # 4. Constant-time compare.
    return hmac.compare_digest(expected, v1)

In Python, read raw_body as bytes (for example request.get_data() in Flask or await request.body() in FastAPI), not as a parsed dict, so the signed bytes match what Cooledge signed.

PHP

<?php

function verify(string $rawBody, string $signatureHeader, string $secret): bool
{
    // 1. Parse the header into t and v1.
    $parts = [];
    foreach (explode(',', $signatureHeader) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        $parts[trim($key)] = trim($value);
    }
    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if ($t === '' || $v1 === '') {
        return false;
    }

    // 2. Reject if the timestamp is more than 300 seconds from now.
    if (abs(time() - (int) $t) > 300) {
        return false;
    }

    // 3. Recompute the HMAC over "<t>.<rawBody>" as lowercase hex.
    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);

    // 4. Constant-time compare.
    return hash_equals($expected, $v1);
}

In PHP, read the raw body with file_get_contents('php://input') so you sign the same bytes Cooledge signed, not a re-encoded array.

Idempotency and at-least-once delivery

Cooledge delivers at least once. The same event can reach you more than once, because a retry can land after your endpoint already processed the first attempt, or because you replayed it from the portal. So your handler must be idempotent.

Dedupe on X-Cooledge-Event-Id. It is stable across every redelivery and replay of the same event, so it is the right key. Store the event ids you have already processed and skip any you have seen before:

event_id = request.headers["X-Cooledge-Event-Id"]
if already_processed(event_id):
    return ("", 200)  # Acknowledge the duplicate, do nothing.

handle_event(payload)
mark_processed(event_id)
return ("", 200)

Do not dedupe on X-Cooledge-Delivery-Id. That id changes on every attempt, so it will never match a previous one and every duplicate would slip through.

Retries and dead-lettering

Cooledge treats a delivery as failed if your endpoint returns a non-2xx status or the connection errors. Failed deliveries are retried automatically with exponential backoff. A delivery is attempted up to 6 times in total, the first attempt plus 5 retries, spread over several hours to give a recovering endpoint time to come back.

If every attempt fails, the delivery is dead-lettered: marked dead and no longer retried automatically. You can then replay it from the portal once your endpoint is healthy. Dead-lettering means a sustained outage on your side does not silently drop events, it parks them for you to recover.

To stay healthy under this model:

  • Respond 2xx promptly. A fast 2xx is how you acknowledge a delivery. Anything else is read as a failure and triggers a retry.
  • Do heavy work asynchronously. Validate the signature, enqueue the event and return 2xx. If you do slow work inline you risk timing out, which Cooledge reads as a failure and retries, so you get the work twice.
  • Expect duplicates. Retries and replays mean the same event can arrive again, which is why deduping on the event id is not optional.

Replaying from the portal

Every delivery, including dead-lettered ones, can be replayed by hand from the subscription's delivery log in Settings → Integrations → Webhooks. A replay sends the same event again with the same X-Cooledge-Event-Id, so a correctly idempotent handler will recognise it and not double-process. The X-Cooledge-Delivery-Id will be new, because a replay is a fresh attempt.

Use replay to recover after an outage: fix your endpoint, confirm it is returning 2xx, then replay the deliveries that failed while it was down.

Where to go next

  • The events index lists every event type you can subscribe to. Each event has its own page with the exact payload shape.
  • Authentication covers the scope list, including webhooks:manage for the subscription API.
  • Pagination and errors and Rate limits cover the rest of the REST API you will call alongside webhooks.

Need a hand with an integration? Contact support