Developer docs · Webhooks
Verify every webhook before you trust it.
Every webhook RecoupIQ dispatches carries an X-RecoupIQ-Signature header containing an HMAC-SHA256 over the request body. Your receiver must verify it before acting on the payload. The verification scheme is identical to Stripe’s, if you already verify Stripe webhooks, the code is the same shape.
1. What the header looks like
POST /your-webhook-endpoint HTTP/1.1
Host: your.domain
Content-Type: application/json
User-Agent: RecoupIQ-Webhook-Dispatcher/2.0
X-RecoupIQ-Event: gazette.insolvency.published
X-RecoupIQ-Signature: t=1715520000,v1=8a3f9e1b...d4
{"event":"gazette.insolvency.published","timestamp":"2026-05-12T10:23:00Z","data":{...}}Components:
t=<unix-seconds>, when we signed the request.v1=<hex>, HMAC-SHA256 of`${t}.${rawBody}`using your subscription’swebhookSecretas the key.
2. The verification recipe
- Read the
X-RecoupIQ-Signatureheader. Split on,and parse thetandv1values. - Reject the request if
|now - t| > 300 seconds(replay protection). - Compute
HMAC-SHA256(`$${t}.${rawBody}`, webhookSecret). Use the raw bytes of the request body, do not parse and re-serialise. - Constant-time compare the result to
v1. Mismatch = reject. - Only after both checks pass, parse the JSON body and act on the event.
3. Node.js / TypeScript receiver
import { createHmac, timingSafeEqual } from 'node:crypto';
import { Request, Response } from 'express';
const WEBHOOK_SECRET = process.env.RECOUPIQ_WEBHOOK_SECRET!; // whsec_...
const REPLAY_WINDOW_SECONDS = 300;
export function recoupiqWebhook(req: Request, res: Response) {
// 1. Read raw body, make sure your framework hasn't parsed it.
// In Express: app.use('/recoupiq', express.raw({ type: 'application/json' }));
const rawBody = (req.body as Buffer).toString('utf8');
// 2. Parse signature header.
const header = String(req.header('X-RecoupIQ-Signature') ?? '');
const parts = Object.fromEntries(
header.split(',').map(p => p.split('=') as [string, string])
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) {
return res.status(400).send('malformed signature header');
}
// 3. Replay protection.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > REPLAY_WINDOW_SECONDS) {
return res.status(400).send('replay window exceeded');
}
// 4. Recompute HMAC and constant-time compare.
const expected = createHmac('sha256', WEBHOOK_SECRET)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(400).send('invalid signature');
}
// 5. Trusted, handle the event.
const event = JSON.parse(rawBody);
// event.event === 'gazette.insolvency.published'
// event.data === { company_number, insolvency_practitioner, ip_number, last_day_for_claims, ... }
// Always respond fast; do real work in a queue.
return res.status(204).send();
}4. Python (FastAPI) receiver
import hmac
import hashlib
import os
import time
from fastapi import FastAPI, Request, HTTPException
WEBHOOK_SECRET = os.environ["RECOUPIQ_WEBHOOK_SECRET"].encode() # b"whsec_..."
REPLAY_WINDOW = 300
app = FastAPI()
@app.post("/recoupiq")
async def recoupiq_webhook(request: Request):
# 1. Read raw body, do not let FastAPI parse it.
raw = await request.body()
# 2. Parse signature header.
sig_header = request.headers.get("X-RecoupIQ-Signature", "")
parts = dict(p.split("=", 1) for p in sig_header.split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
raise HTTPException(status_code=400, detail="malformed signature header")
# 3. Replay protection.
if abs(int(time.time()) - t) > REPLAY_WINDOW:
raise HTTPException(status_code=400, detail="replay window exceeded")
# 4. Recompute HMAC and constant-time compare.
signed_payload = f"{t}.".encode() + raw
expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1):
raise HTTPException(status_code=400, detail="invalid signature")
# 5. Trusted, handle the event.
import json
event = json.loads(raw)
# event["event"] == "gazette.insolvency.published"
# event["data"]["insolvency_practitioner"] etc.
return {"received": True}5. Bash diagnostic (for testing only)
Useful when you receive a webhook locally and want to verify it from a shell rather than via your application:
WEBHOOK_SECRET="whsec_..." # your subscription secret
RAW_BODY='<paste the raw request body here>'
T="<paste the t value from X-RecoupIQ-Signature>"
V1_RECEIVED="<paste the v1 value>"
V1_EXPECTED=$(printf '%s' "${T}.${RAW_BODY}" | \
openssl dgst -sha256 -hmac "${WEBHOOK_SECRET}" -hex | \
awk '{print $2}')
if [ "$V1_RECEIVED" = "$V1_EXPECTED" ]; then
echo "valid"
else
echo "INVALID"
fi6. Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| All signatures fail | Framework parsed-and-reserialised the body | Use the raw bytes of the request body, same bytes that went on the wire |
| Intermittent failures | Clock drift between your server and ours | NTP-sync your receiver; the 5-min window is generous but not infinite |
| Wrong secret | Subscription was recreated; old secret invalid | Recreate the subscription; new secret returned in POST response |
| Length-mismatch error | Hex parsing dropped leading zeros | Compare hex strings as fixed-length 64-char strings; or decode to bytes first |
7. Where to get your webhook secret
Call POST /api/v1/watch with a webhookUrl. The response body includes data.webhookSecret, a whsec_-prefixed 64-character hex string. Shown once. Store it on your side; we never display it again. If lost, recreate the subscription to receive a new one.
8. Event types, what you receive
Every webhook carries an event field naming the signal-change type that triggered it. Subscribers can opt in to a subset by passing event_types on POST /api/v1/watch, when omitted, the subscription receives ALL event types (legacy behaviour preserved). Use the canonical event strings below verbatim.
| Event type | Fired when |
|---|---|
| gazette.insolvency.published | UK Gazette insolvency notice published (Administration, CVL, CVA, winding-up). |
| companies_court.petition.scheduled | A winding-up or administration petition for the watched company is listed on a UK Companies Court cause list. 4-6 weeks EARLIER than the Gazette. |
| ch.charge.registered | A new outstanding charge has been registered against the company (CA 2006 Part 25). Secured creditor sitting ahead of you in any future insolvency. |
| sanctions.ofsi.match_new | Director or PSC newly matches the OFSI Consolidated List. Stop-the-line under SAMLA 2018. |
| fca.warning.match_new | Company or director newly matches the FCA Warning List of unauthorised firms. |
| cdda.disqualification.match_new | Active officer newly matches the CH Disqualifications register (CDDA 1986). |
| phoenix.six_indicator.flagged | Six-Indicator Phoenix Score newly fires for this counterparty (INSS-published methodology). |
| ch.officer.appointed | New active officer appointed to the company. |
| ch.officer.resigned | Active officer has resigned from the company. |
| ch.psc.changed | Beneficial-ownership chain has materially changed. |
| eccta.director_id.unverified | Director ID-verification status has flipped (ECCTA 2023). |
| sic_code.changed | Company's primary SIC code has changed on its latest CS01 filing. |
| hmlr.title.added | HMLR property title newly registered to the company. Recoverable-asset signal. |
| hmlr.title.disposed | HMLR property title disposed of. Recovery signal change. |
| offshore.control.flagged | Offshore-control pattern flagged (HMLR OCOD ⋈ CH ROE). FATF high-risk jurisdiction watchpoint. |
| roe.entity.changed | Register of Overseas Entities event affecting the company. |
| hmrc.defaulter.match_new | Company newly appears on the HMRC published-defaulter list. |
| news.distress.detected | News-distress signal detected from INSS RSS or UK press. |
| capital_bleed.flagged | Capital-bleed velocity has crossed the 25%/YoY threshold. |
| cooked_books.flagged | iXBRL filings reconstruction loss above the 99th-percentile cohort threshold. |
| sector_stress.elevated | Sector-level stress (cluster-momentum) has been classified as elevated. Watchlist-portfolio-wide event. |
Every event payload carries companyNumber, companyName (when known), sourceId (idempotency key), detail (one-line plain-English description), and an optional metadata object with event-specific fields. Receivers MUST dedupe on (event, data.companyNumber, data.sourceId), re-deliveries during retry windows are normal.