Skip to main content
digital accessibility compliance for state and local government
Scan a site

Webhooks

Last updated 28 October 2026 · API version v1

Nine events, sent to the endpoints an organisation sets up. Each delivery is signed, retried when it fails, and kept in a log you can read and replay.

The events

Every webhook event, when it is sent and the fields of its data. 9 rows.
EventWhen it is sentdata
scan.completedA scan finishedscanId, propertyId, url
scan.failedA scan could not finishscanId, propertyId, url
issue.discoveredAn issue was found for the first timeissueId, propertyId, scanId, url
issue.regressedAn issue was found again on a page where a scan or a journey run had stopped finding itissueId, propertyId, scanId, url
issue.ready_to_verifyAn issue was reported gone and is waiting to be verifiedissueId, propertyId, url
issue.verifiedAn issue was confirmed gone, by a scan or by a person who retested itissueId, propertyId, url
document.validatedA document finished validationdocumentId, propertyId, url
vendor.evidence_staleA vendor product’s VPAT became more than 24 months oldvendorProductId, propertyId, url
report.readyA generated report is ready to downloadreportId, propertyId, url

There is no issue.fixed. A defect reported gone is not yet a defect known to be gone, and an event called fixed would say the second when only the first has happened. So the event fires under the name of the transition that actually happened: issue.ready_to_verify when every occurrence of an issue has been reported gone and nothing has confirmed it, and issue.verified when a scan or a person’s retest has confirmed it. issue.verified is the one a dashboard should count.

Going back to open sends no event of its own, and an issue.regressed comes with it only when a page comes back: one a scan or a journey run had stopped finding the issue on. So when a person or a key moves an issue to ready_to_verify or verified and a later scan, journey run or manual test still finds it on the pages it was on, the issue goes back to open and no event is sent about it. The same is true of an issue found again only on pages it was not on before. The exception is a scan in which more than 20% of the pages it tried could not be tested: it moves no issue and brings no page back. To follow an issue’s status, read it at /issues/{id}.

Webhook payloads never contain full findings. They carry ids and a link. The url in data is the resource on this API. Nothing sensitive is sent to any endpoint: a receiver reads the details from the API, with its own key, so a payload stays small.

Endpoints and their secret

An endpoint belongs to one organisation. An admin or an owner adds it under Settings, Webhooks: an https:// address, and the events it should receive. Its signing secret starts whsec_ and is shown in full once, when the endpoint is created. It is stored encrypted rather than hashed, because every delivery is signed with it.

An endpoint receives the events of its own organisation and no other, so an agency receives a client’s events through an endpoint in that client’s organisation. An endpoint can be paused, and no event is sent to it while it is.

What a delivery looks like

A POST with a JSON body, and these headers on every attempt:

Content-Type
application/json, always.
User-Agent
BarrierOps-Webhooks/1.0, so the request is recognisable in a log.
BarrierOps-Event
The event’s type, so a receiver can route before it reads the body.
BarrierOps-Event-Id
The event’s id, the same on every attempt and every replay of it. Handle each id once and a retry is handled once.
BarrierOps-Delivery-Id
This delivery’s id. A replay is a new delivery of the same event, with its own.
BarrierOps-Signature
A timestamp and one signature, t=…,v1=…, or two signatures while a secret is being rolled. See below.

This is one, for a scan City of Riverbend finished in the sandbox. The body is shown as it is sent, on one line, because the signature is over these exact bytes; its event id, its delivery id and its time are this example’s, and every real event has its own.

http: the headers and body of one delivery

Content-Type: application/json
User-Agent: BarrierOps-Webhooks/1.0
BarrierOps-Event: scan.completed
BarrierOps-Event-Id: ce8450ac-2be2-512a-b87a-fc929ee54094
BarrierOps-Delivery-Id: 006d4f75-f872-544f-9677-bf76d23125b6
BarrierOps-Signature: t=1791561600,v1=a8ecdad0acc4725ac7c3722b7c3612ebacfadc5a168ddf78fa6c5c9d1bcbafcf

{"id":"ce8450ac-2be2-512a-b87a-fc929ee54094","type":"scan.completed","created":"2026-10-09T16:00:00.000000+00:00","orgId":"b7749447-87f3-5d35-beee-99e152f35574","test":false,"data":{"scanId":"50763701-e469-5512-96d1-4181fa293810","propertyId":"9ccf6432-ad06-5afe-8a30-52373a0346a4","url":"https://api.barrierops.com/api/v1/scans/50763701-e469-5512-96d1-4181fa293810"}}

Lines are wrapped for printing. The original is one line.

And the body of an issue.verified event, for an issue the sandbox has verified, formatted for reading:

json: an issue.verified message, formatted

{
  "id": "dadaf398-7b63-5294-864a-27b935ab3cea",
  "type": "issue.verified",
  "created": "2026-10-09T16:00:00.000000+00:00",
  "orgId": "b7749447-87f3-5d35-beee-99e152f35574",
  "test": false,
  "data": {
    "issueId": "0f20676e-b8ed-5a45-9719-424c620317b0",
    "propertyId": "9ccf6432-ad06-5afe-8a30-52373a0346a4",
    "url": "https://api.barrierops.com/api/v1/issues/0f20676e-b8ed-5a45-9719-424c620317b0"
  }
}

Lines are wrapped for printing. The original is one line.

Verifying a delivery

Verify the signature against the raw request body, before any JSON parsing. A framework that re-serialises the body will break the signature.

  1. Read the BarrierOps-Signature header and split it on commas: t is the Unix time it was signed, and each v1 is a signature.
  2. Compute HMAC-SHA256 of the timestamp, a full stop and the raw body — <t>.<raw body> — keyed with the endpoint’s secret exactly as it was shown, whsec_ included, and write it as lowercase hex.
  3. Compare it with each v1 in constant time. One match is enough.
  4. Reject a message whose timestamp is more than 5 minutes from your own clock. An old one is a replay of a message somebody kept; the timestamp is inside what is signed, so it cannot be changed without breaking the signature.

Each sample below is the whole of it, in one function, and CI runs each one against messages the worker’s own signer signs. Each returns true for the example above when it is given the example’s secret and its own clock set to the example’s time: whsec_LUtPJ2jrT-6J9FKNI34OMxFegUPO22FMN3_-4jnpYBk and 1791561600.

TypeScript (Node)

typescript: verify a delivery’s signature in Node

// Check a BarrierOps webhook's signature before you parse its body. Node 18 or later.
import { createHmac, timingSafeEqual } from 'node:crypto';

/** How far the signed timestamp may be from your clock, either way: five minutes. */
const TOLERANCE_SECONDS = 300;

/**
 * True when `header`, the BarrierOps-Signature header, signs `rawBody` with `secret`
 * and was signed within five minutes of `now`.
 *
 * `rawBody` is the request body exactly as it arrived, as bytes: read it before any
 * JSON middleware runs, because a body that is parsed and serialised again is a
 * different text. `secret` is the endpoint's signing secret exactly as it was shown,
 * `whsec_` included.
 */
export function verifyBarrierOpsSignature(
  rawBody: Buffer | string,
  header: string,
  secret: string,
  now: Date = new Date(),
): boolean {
  let timestamp: number | undefined;
  const signatures: Buffer[] = [];

  for (const part of header.split(',')) {
    const [name, value = ''] = part.trim().split('=', 2);
    if (name === 't' && /^[0-9]+$/.test(value)) {
      timestamp = Number(value);
    } else if (name === 'v1' && /^[0-9a-f]{64}$/.test(value)) {
      signatures.push(Buffer.from(value, 'hex'));
    }
  }

  if (timestamp === undefined || Math.abs(now.getTime() / 1000 - timestamp) > TOLERANCE_SECONDS) {
    return false;
  }

  const expected = createHmac('sha256', secret)
    .update(`${String(timestamp)}.`)
    .update(rawBody)
    .digest();

  // While a secret is being rolled the header carries two v1 values: either one will do.
  return signatures.some((signature) => timingSafeEqual(signature, expected));
}

Lines are wrapped for printing. The original is one line.

Python

python: verify a delivery’s signature in Python

# Check a BarrierOps webhook's signature before you parse its body.
# Python 3.8 or later, standard library only.
import hashlib
import hmac
import re
import time

# How far the signed timestamp may be from your clock, either way: five minutes.
TOLERANCE_SECONDS = 300


def verify_barrierops_signature(raw_body, header, secret, now=None):
    """True when header, the BarrierOps-Signature header, signs raw_body with secret
    and was signed within five minutes of now (Unix seconds; the current time when
    omitted).

    raw_body is the request body exactly as it arrived, as bytes: read it before
    anything parses the JSON, because a body that is parsed and serialised again is
    a different text. secret is the endpoint's signing secret exactly as it was
    shown, whsec_ included.
    """
    timestamp = None
    signatures = []

    for part in header.split(","):
        name, _, value = part.strip().partition("=")
        if name == "t" and re.fullmatch(r"[0-9]+", value):
            timestamp = int(value)
        elif name == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
            signatures.append(value)

    current = time.time() if now is None else now
    if timestamp is None or abs(current - timestamp) > TOLERANCE_SECONDS:
        return False

    signed = str(timestamp).encode("ascii") + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()

    # While a secret is being rolled the header carries two v1 values: either one will do.
    return any(hmac.compare_digest(expected, signature) for signature in signatures)

Lines are wrapped for printing. The original is one line.

PHP

For a government CMS receiving the webhook. The body is file_get_contents('php://input'), read before anything decodes it.

php: verify a delivery’s signature in PHP

<?php
// Check a BarrierOps webhook's signature before you parse its body. PHP 7.4 or later.

/**
 * True when $header, the BarrierOps-Signature header, signs $rawBody with $secret and was
 * signed within five minutes of $now (Unix seconds; the current time when omitted).
 *
 * $rawBody is the request body exactly as it arrived -- file_get_contents('php://input'),
 * before json_decode -- because a body that is decoded and encoded again is a different
 * text. $secret is the endpoint's signing secret exactly as it was shown, whsec_ included.
 */
function verify_barrierops_signature(string $rawBody, string $header, string $secret, ?int $now = null): bool
{
    // How far the signed timestamp may be from your clock, either way: five minutes.
    $toleranceSeconds = 300;
    $timestamp = null;
    $signatures = [];

    foreach (explode(',', $header) as $part) {
        $pair = explode('=', trim($part), 2);
        if (count($pair) !== 2) {
            continue;
        }
        [$name, $value] = $pair;
        if ($name === 't' && preg_match('/\A[0-9]+\z/', $value) === 1) {
            $timestamp = (int) $value;
        } elseif ($name === 'v1' && preg_match('/\A[0-9a-f]{64}\z/', $value) === 1) {
            $signatures[] = $value;
        }
    }

    if ($timestamp === null || abs(($now ?? time()) - $timestamp) > $toleranceSeconds) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    // While a secret is being rolled the header carries two v1 values: either one will do.
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

Lines are wrapped for printing. The original is one line.

Answer a verified delivery with a 2xx as soon as you have stored it, and do the work afterwards: the delivery waits 10 seconds for an answer, and one that takes longer counts as not delivered. Delivery is at least once, and in no set order — handle each event id once.

Rolling the secret

Rolling an endpoint’s secret shows a new one, once, and keeps signing with the previous one as well for the overlap you choose: 1 hour, 24 hours, 7 days or 30 days — 24 hours unless you choose otherwise. During the overlap the header carries two v1 values, the new secret’s first, so a receiver that still holds the old secret goes on verifying:

http: the signature header while a secret is being rolled

BarrierOps-Signature: t=1791561600,v1=a8ecdad0acc4725ac7c3722b7c3612ebacfadc5a168ddf78fa6c5c9d1bcbafcf,v1=844d87ab494e03728673d86b23f300b7c2a57c72fdd1770b698773f1747a628b

Lines are wrapped for printing. The original is one line.

Each sample above accepts a message when any v1 matches, so a receiver needs no change to get through a roll: deploy the new secret within the overlap. Rolling again during an overlap starts a new one, and the oldest secret stops at once.

Retries, and an endpoint that keeps failing

A delivery counts as delivered on a 2xx response and on nothing else; a redirect is not followed. One that is not delivered is tried again, 8 attempts in all: the first at once, then 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 8 hours and 24 hours after it. Every attempt is signed afresh, with its own timestamp.

If the last attempt fails and nothing has been delivered to that endpoint since the first one, the endpoint is disabled and the organisation’s owners and admins are emailed. While it is disabled, nothing is sent to it: its deliveries are held, and can be replayed once it is enabled again.

The delivery log and replay

When an integration breaks at 2am, you need to see what we sent. The delivery log shows every attempt and supports replay.

The log is under Settings, Webhooks. For each delivery it shows every attempt: the request as it was sent, signature included, the status that came back, and the first 2,048 characters of the response. A replay sends the same event again — the same event id, the same body, a fresh timestamp and signature — as a new delivery, and a test event sends one scan.completed marked "test": true. Deliveries are kept for 30 days.

https://barrierops.com/docs/webhooks