Skip to content
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 2xx within 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:

  • markdown is the article body exactly as it was delivered, which means it already ends with a ## Sources section listing each verified claim and the page it was checked against. html is 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 called append_sources_section, and the only way to change it today is PATCH /v1/projects/{id} carrying the whole settings object, since that request replaces the object rather than merging into it. Set it to false and both fields arrive without the block, while citations below is unaffected either way.

  • meta contains the SEO title and description. frontmatter contains BirchSeek’s canonical article fields.

  • citations contains every sourced claim that survived review, each with its verification_status. It is the wider set: the ## Sources block in the body lists only the ones that came back verified.

  • source_quote is 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_method says which check produced verification_status, and it is the only field that tells you whether source_quote is actually on the cited page:

    Value What it proves
    substring The 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-date matches a page that writes up to date, and a $ or a % is folded away on both sides. Only this value licenses showing source_quote as a quotation from the source. Where source_quote is null, 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.
    fuzzy A passage of the page overlapped the excerpt closely enough to pass, but the excerpt is not on that page as written.
    llm Neither 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 fuzzy or llm: 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: substring with an excerpt (the excerpt is on the page), substring with source_quote null (only the claim’s own wording is on the page, and there is nothing to quote), fuzzy, and llm. verified is those four different facts wearing one word, so a renderer that ignores this field and prints source_quote under the text found on that page is making a claim BirchSeek did not - which is the whole reason the field ships. Where it is not substring, attribute the excerpt to the article, not to the source.

  • faq is an array of {question, answer} pairs mirroring the article’s FAQ section - useful for emitting FAQPage structured data on your side.

  • publish_date and updated_date are YYYY-MM-DD dates. updated_date and author are omitted when absent.

  • Treat unknown fields as forward-compatible: we add fields without notice, we never repurpose or remove them within v1 signatures.

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:

  1. Parse the header into t (unix seconds) and every v1 value.
  2. Reject stale timestamps: if |now − t| > 300 seconds, reject the delivery. This is the replay-protection window.
  3. 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.
  4. Compare in constant time against the v1 value. 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-Id UUID. Deduplicate on it - if you’ve processed the delivery, acknowledge with a 2xx and 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.