All docs
Webhook reference
Credential: Signing secret
BirchSeek delivers every published article to your endpoint as an HTTPS POST with a JSON body, signed with HMAC-SHA256 so you can prove it came from us and was not altered. The signature scheme matches Stripe’s in shape.
Webhook endpoint requirements
- HTTPS only. Plain HTTP endpoints are rejected at configuration time.
- Respond
2xxwithin 30 seconds. Anything else - including timeouts and redirects - counts as a failed delivery and is retried. - Do the work asynchronously. Acknowledge first, process after.
Delivery headers
| Header | Contents |
|---|---|
X-BirchSeek-Event |
Event type. Currently article.published for real deliveries and ping for connector tests. |
X-BirchSeek-Signature |
t={unix_seconds},v1={hex_hmac} - see Verifying signatures. |
X-BirchSeek-Delivery-Id |
A UUID identifying this delivery. Retries reuse the same value - treat it as your idempotency key and drop duplicates. |
Content-Type |
application/json |
The article.published payload
{
"event": "article.published",
"article": {
"id": "0197f9c8-1a2b-7d4e-8f3c-5b6a7c8d9e0f",
"title": "How to choose a CRM in 2026",
"slug": "how-to-choose-a-crm",
"tags": ["crm", "buying-guides"],
"publish_date": "2026-07-17",
"author": "Jane Editor",
"markdown": "First CRMs rarely stick: 38% of small teams replace their first CRM within two years.\n\n## Sources\n\n1. 38% of small teams replace their first CRM within two years — [Small-team CRM replacement rates](https://example.com/research/crm-replacement-2026)\n",
"html": "<p>First CRMs rarely stick: 38% of small teams replace their first CRM within two years.</p>\n<h2>Sources</h2>\n<ol>\n<li>38% of small teams replace their first CRM within two years — <a href=\"https://example.com/research/crm-replacement-2026\">Small-team CRM replacement rates</a></li>\n</ol>\n",
"meta": {
"title": "How to Choose a CRM in 2026: A Data-Backed Buyer's Guide",
"description": "A buyer's guide built on what the data actually says."
},
"frontmatter": {
"title": "How to choose a CRM in 2026",
"description": "A buyer's guide built on what the data actually says.",
"publish_date": "2026-07-17",
"tags": ["crm", "buying-guides"],
"faq": [
{
"question": "How long do teams keep their first CRM?",
"answer": "Most small teams replace it inside two years, which is why migration cost belongs in the comparison."
}
],
"author": "Jane Editor"
},
"citations": [
{
"claim_text": "38% of small teams replace their first CRM within two years",
"claim_type": "statistic",
"source_url": "https://example.com/research/crm-replacement-2026",
"source_title": "Small-team CRM replacement rates",
"source_quote": "Across the sample, 38% of teams under 50 people had replaced their first CRM inside two years.",
"verification_status": "verified",
"verification_method": "substring"
}
],
"faq": [
{
"question": "How long do teams keep their first CRM?",
"answer": "Most small teams replace it inside two years, which is why migration cost belongs in the comparison."
}
]
}
}
Field notes:
-
markdownis the article body exactly as it was delivered, which means it already ends with a## Sourcessection listing each verified claim and the page it was checked against.htmlis BirchSeek’s rendered form of that same string, so do not append the citations yourself - render the body and the block is there. The block is on by default, and there is no switch for it in the dashboard: it is a project setting calledappend_sources_section, and the only way to change it today isPATCH /v1/projects/{id}carrying the whole settings object, since that request replaces the object rather than merging into it. Set it tofalseand both fields arrive without the block, whilecitationsbelow is unaffected either way. -
metacontains the SEO title and description.frontmattercontains BirchSeek’s canonical article fields. -
citationscontains every sourced claim that survived review, each with itsverification_status. It is the wider set: the## Sourcesblock in the body lists only the ones that came backverified. -
source_quoteis the excerpt the writer asserted, not text BirchSeek found on the page. It is written when the claim is created and never rewritten - verification reads it and never corrects it. Do not render it as a quotation from the source without reading the next field first. -
verification_methodsays which check producedverification_status, and it is the only field that tells you whethersource_quoteis actually on the cited page:Value What it proves substringThe excerpt was found in the page’s text, matched word for word - with case set aside and every run of characters that are not letters or digits collapsed to a space, so up-to-datematches a page that writesup to date, and a$or a%is folded away on both sides. Only this value licenses showingsource_quoteas a quotation from the source. Wheresource_quoteisnull, the writer asserted no excerpt and this arm matched the claim text itself on the page - so the check passed with no excerpt in the record at all, and there is nothing to render as a quotation.fuzzyA passage of the page overlapped the excerpt closely enough to pass, but the excerpt is not on that page as written. llmNeither matcher found the excerpt - a model read the page and judged it to support the claim. The excerpt as written was not located. absent The method was not recorded. Treat exactly as you would fuzzyorllm: unproven.The field is optional and unknown values must degrade to “not recorded” rather than to a match. Read it together with
source_quote, because the two of them decide the answer between four cases, not three:substringwith an excerpt (the excerpt is on the page),substringwithsource_quotenull (only the claim’s own wording is on the page, and there is nothing to quote),fuzzy, andllm.verifiedis those four different facts wearing one word, so a renderer that ignores this field and printssource_quoteunder the text found on that page is making a claim BirchSeek did not - which is the whole reason the field ships. Where it is notsubstring, attribute the excerpt to the article, not to the source. -
faqis an array of{question, answer}pairs mirroring the article’s FAQ section - useful for emittingFAQPagestructured data on your side. -
publish_dateandupdated_dateareYYYY-MM-DDdates.updated_dateandauthorare omitted when absent. -
Treat unknown fields as forward-compatible: we add fields without notice, we never repurpose or remove them within
v1signatures.
The ping test body is {"event":"ping","message":"BirchSeek connector test — respond with any 2xx status."} and is signed identically, so Send a test exercises the same verification code before an article ships.
Your signing secret
When you create a webhook connector, BirchSeek generates a secret of the form whsec_{base64url} and shows it exactly once. Store it in your secret manager; it is encrypted at rest and cannot be re-displayed. Recreate the connector to rotate it.
Verifying signatures
The signature header looks like:
X-BirchSeek-Signature: t=1752743400,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
To verify:
- Parse the header into
t(unix seconds) and everyv1value. - Reject stale timestamps: if
|now − t| > 300seconds, reject the delivery. This is the replay-protection window. - Compute the expected signature:
HMAC-SHA256(secret, "{t}." + raw_body), hex-encoded. The signed message is the timestamp, a literal., then the raw request bytes - not re-serialized JSON. Read the body before any JSON parsing middleware touches it. - Compare in constant time against the
v1value. If it matches, the delivery is authentic.
Node.js
import crypto from "node:crypto";
function verify(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
const parts = signatureHeader.split(",");
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2));
const signatures = parts.filter((p) => p.startsWith("v1=")).map((p) => p.slice(3));
if (!Number.isFinite(t) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody) // Buffer of the raw request body
.digest("hex");
return signatures.some((sig) => {
const a = Buffer.from(sig, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
Python
import hashlib
import hmac
import time
def verify(raw_body: bytes, signature_header: str, secret: str,
tolerance_seconds: int = 300) -> bool:
parts = signature_header.split(",")
t = next((p[2:] for p in parts if p.startswith("t=")), None)
signatures = [p[3:] for p in parts if p.startswith("v1=")]
if t is None or not t.isdigit() or not signatures:
return False
if abs(time.time() - int(t)) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)
Common mistakes: verifying against the parsed-then-re-serialized body (key order changes, verification fails), reading the body as UTF-8 text with normalization applied, and comparing with == instead of a constant-time function.
Retries, idempotency, and endpoint health
- Failed deliveries use a backoff sequence of 1 minute, 5 minutes, 30 minutes, 2 hours, and 6 hours after consecutive failures. The retry scanner runs every five minutes, so an attempt can start later than its eligibility time.
- Every retry carries the same
X-BirchSeek-Delivery-IdUUID. Deduplicate on it - if you’ve processed the delivery, acknowledge with a2xxand do nothing. - Delivery attempts and bounded response snippets are retained for retry bookkeeping.
- After the retry schedule is exhausted, the delivery is marked dead, the connector is marked failing, and the project owner is emailed.
Testing
Use Send a test on the webhook connector (in onboarding or under Connectors). BirchSeek sends a signed ping payload and reports the response status. Non-2xx responses include a bounded error snippet.