API and developers
Webhooks: get changes sent to your system
We can send each change to a company you monitor to your own HTTPS address, signed with HMAC-SHA256. How to set it up, check the signature, and handle retries.
A webhook sends each change we find on a monitored company to an address on your own system, as it happens. We check for changes every 15 minutes.
Who can use webhooks
- API, any plan, including Free. Set webhooks on companies you watch, or on lists.
- Web app Team. Turn on webhooks in a list's settings.
Set up a webhook
In the web app (Team): open the list, open its settings, find Monitoring and alerts, turn on Send changes to a webhook and enter the Webhook URL.
Through the API:
- For one company:
POST /watchlistwith{"company_number": "00445790", "webhook_url": "https://example.com/hooks/companystack"}. - For a list:
PATCH /lists/{id}with{"webhook_url": "https://example.com/hooks/companystack", "alert_webhook": true}.
If a company has its own webhook address, it is used instead of the list's. You get one webhook per change, even if the company is on several of your lists.
Your address must use https on port 443, with a public host name. Addresses with a username or password in them, or private network addresses, are refused.
What we send
A POST with a JSON body:
{
"id": "evt_123456_789",
"type": "company.alert",
"created_at": "2026-10-01T09:15:02.000Z",
"data": {
"alert_id": 123456,
"company_number": "00445790",
"company_name": "EXAMPLE LIMITED",
"alert_type": "gazette_notice",
"severity": "critical",
"title": "…",
"detail": "…",
"detected_at": "2026-10-01T09:15:02.000Z",
"links": {
"company": "https://api.companystack.co.uk/company/00445790",
"alerts": "https://api.companystack.co.uk/watchlist/alerts?company_number=00445790"
},
"lists": [{ "id": 42, "name": "Suppliers" }]
}
}
alert_type is one of: status_change, name_change, address_change, sic_change, accounts_filed, accounts_overdue, confirmation_statement_filed, confirmation_statement_overdue, charge_registered, charge_satisfied, psc_change, gazette_notice, new_filing, officer_change or financials_available. severity is info, warning or critical.
Each request has these headers:
CompanyStack-Signature: the signature (see below);CompanyStack-Event-Id: the same asid. It stays the same when we retry, so use it to ignore duplicates;CompanyStack-Event-Type: for examplecompany.alert.
Check the signature
Every webhook is signed, so you can be sure it came from us. The header looks like this:
CompanyStack-Signature: t=1759310102,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
tis the time we signed it, in Unix seconds.v1is the hex HMAC-SHA256 of the text<t>.<raw request body>, using your webhook secret as the key.
Get your secret with your API key: GET /watchlist/webhook-secret. It starts with whsec_. Use the whole string, including whsec_, as the key. Web app users can call this with the API key created when they signed up, from the API keys page.
To check a request:
- Read the raw request body exactly as received. Do not parse and re-encode the JSON first.
- Take
tand everyv1value from the header. - Reject the request if
tis more than 5 minutes from your current time. - Work out HMAC-SHA256 of
t+.+ the raw body, with your secret, as hex. - Accept the request if it matches any
v1value. Compare in constant time.
Node.js:
import crypto from 'node:crypto';
export function verifyCompanyStackSignature(rawBody, header, secret, toleranceSeconds = 300) {
if (!header) return false;
let timestamp = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value] = part.trim().split('=', 2);
if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value);
if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest();
return signatures.some(sig => crypto.timingSafeEqual(Buffer.from(sig, 'hex'), expected));
}
Python:
import hashlib
import hmac
import time
def verify_companystack_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
if not header:
return False
timestamp = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and len(value) == 64:
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > tolerance_seconds:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
Rotate the secret
POST /watchlist/webhook-secret/rotate creates a new secret. For the next 24 hours we sign with both the new and the old secret, so the header carries two v1 values. Update your system within that time.
Respond quickly
Reply with any 2xx status within 10 seconds. Do slow work after you reply.
If you do not, we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours: 8 attempts in all. After that the delivery is marked as failed.
Test and troubleshoot
- Send a test:
POST /watchlist/webhooks/testwith{"url": "https://example.com/hooks/companystack"}sends a signedwebhook.testevent straight away. It is not retried. - See deliveries:
GET /watchlist/webhooks/deliveries?status=failedlists recent deliveries, withpending,deliveredorfailedstatus. - Signature never matches? Most often the body was changed before checking, for example by a framework that parses JSON. Check the raw bytes.
Last updated 2 October 2026
Related articles
- API keys and authentication
- API rate limits and error codes (401, 403, 404, 429)
- Paging through API results
- Connect SI agents with the hosted MCP server
Still need help? Contact us, or if you have an account, open a support request.