Agent skill

Schematic Webhooks Skill

Receive and verify Schematic webhooks. Use when setting up Schematic webhook handlers, debugging X-Schematic-Webhook-Signature verification, or handling company.created, plan.created, flag.updated, subscription.trial.ended and entitlement.limit.reached events.

Install this skill

npx skills add hookdeck/webhook-skills --skill schematic-webhooks


When to Use This Skill

  • How do I receive Schematic webhooks?
  • How do I verify the X-Schematic-Webhook-Signature header?
  • Why is my Schematic webhook signature verification failing?
  • How do I handle company.created, entitlement.limit.reached or subscription.trial.ended events?
  • How do I deduplicate Schematic webhook retries?

Verification (core)

Schematic sends the bare lowercase hex digest (no prefix) in X-Schematic-Webhook-Signature:

HMAC-SHA256(secret, rawBody + "+" + X-Schematic-Webhook-Timestamp) → hex

Build the signed message as bytes: raw body, then a literal +, then the timestamp header string exactly as received (latin1, never trimmed or reformatted). The key is the dashboard secret's UTF-8 bytes, used as-is. Validate the signature is exactly 64 hex characters before decoding; hex is case-insensitive. X-Schematic-Webhook-Signature-Version (v1) is not signed.

Node:

const crypto = require('crypto');

function verify(rawBody, signature, timestamp, secret) {
  if (!signature || !timestamp || !/^[0-9a-fA-F]{64}$/.test(signature)) return false;
  const signed = Buffer.concat([rawBody, Buffer.from('+'), Buffer.from(timestamp, 'latin1')]);
  const expected = crypto.createHmac('sha256', Buffer.from(secret, 'utf8')).update(signed).digest();
  const provided = Buffer.from(signature, 'hex');
  return provided.length === expected.length && crypto.timingSafeEqual(provided, expected);
}

Python:

import hashlib, hmac, re

def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if not signature or not timestamp or not re.fullmatch(r"[0-9a-fA-F]{64}", signature):
        return False
    signed = b"".join([raw_body, b"+", timestamp.encode("latin-1")])
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature), expected)

Read the raw body first (express.raw({ type: '*/*' }), await request.arrayBuffer(), await request.body()) and parse JSON only after the check. A missing/malformed signature or timestamp, or a mismatch, returns 401.

Timestamp window is off by default. Hookdeck retries and replays resend the original headers and timestamp, and Hookdeck applies no tolerance, so a fixed window would reject legitimate retries. The examples offer an opt-in SCHEMATIC_WEBHOOK_TOLERANCE_SECONDS (unset or 0 = off); X-Schematic-Webhook-Timestamp is Unix seconds.

Schematic's SDKs also ship verify helpers (for example verifyWebhookSignature in @schematichq/schematic-typescript-node); the examples verify by hand so the algorithm stays visible and works in every framework.

For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.

Event Envelope

Every delivery is a POST with a JSON body. Dispatch on action:

{ "action": "company.created", "account_id": "acct_...", "environment_id": "env_...",
  "event_id": "whe_...", "object_type": "company", "body": {}, "company_id": "comp_...", "feature_id": "feat_..." }

event_id (also X-Schematic-Webhook-Id) is stable across retries, so deduplicate on it. Dashboard test sends carry neither.

Common Event Types

EventDescription
company.created / company.updated / company.deletedCompany lifecycle
company.plan_changedA company changed plan
user.createdUser created (also user.updated, user.deleted)
plan.createdPlan created (also plan.updated, plan.deleted)
flag.updatedFlag changed (also flag.created, flag.deleted, flag_rules.updated)
subscription.trial.endedA subscription's trial ended
entitlement.limit.reachedA company hit a feature usage limit (also .warning, soft and tier variants)
credit.purchase.successCredits purchased (also auto.topup.success, auto.topup.hard.failure)

All 39 events are listed in references/overview.md.

Important Headers

HeaderDescription
X-Schematic-Webhook-SignatureHex HMAC-SHA256 digest
X-Schematic-Webhook-TimestampUnix seconds when this attempt was sent (signed)
X-Schematic-Webhook-Signature-Versionv1 (not signed)
X-Schematic-Webhook-IdSame as event_id; absent on test sends
X-Schematic-Webhook-AttemptAttempt number, from 1

Environment Variables

SCHEMATIC_WEBHOOK_SECRET=your_schematic_webhook_secret   # Shown in the Schematic dashboard when you create the endpoint
SCHEMATIC_WEBHOOK_TOLERANCE_SECONDS=0                    # Optional replay window; unset or 0 = off

Delivery

Respond with any 2xx within 5 seconds (anything else, 3xx included, counts as failed). Schematic makes eight attempts over about 24 hours, then drops the event. Endpoints with a failure rate of 90% or more across at least 25 events in 24 hours are deactivated. The timestamp and signature are fresh on every attempt, so verify each request on its own.

Local Development

# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 schematic --path /webhooks/schematic

Reference Materials


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 11, 2026

View on GitHub →