# 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:

```javascript
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:

```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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/schematic-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/schematic-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/schematic-webhooks/examples/fastapi/).

## Event Envelope

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

```json
{ "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

| Event | Description |
| --- | --- |
| `company.created` / `company.updated` / `company.deleted` | Company lifecycle |
| `company.plan_changed` | A company changed plan |
| `user.created` | User created (also `user.updated`, `user.deleted`) |
| `plan.created` | Plan created (also `plan.updated`, `plan.deleted`) |
| `flag.updated` | Flag changed (also `flag.created`, `flag.deleted`, `flag_rules.updated`) |
| `subscription.trial.ended` | A subscription's trial ended |
| `entitlement.limit.reached` | A company hit a feature usage limit (also `.warning`, soft and tier variants) |
| `credit.purchase.success` | Credits purchased (also `auto.topup.success`, `auto.topup.hard.failure`) |

All 39 events are listed in [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/schematic-webhooks/references/overview.md).

## Important Headers

| Header | Description |
| --- | --- |
| `X-Schematic-Webhook-Signature` | Hex HMAC-SHA256 digest |
| `X-Schematic-Webhook-Timestamp` | Unix seconds when this attempt was sent (signed) |
| `X-Schematic-Webhook-Signature-Version` | `v1` (not signed) |
| `X-Schematic-Webhook-Id` | Same as `event_id`; absent on test sends |
| `X-Schematic-Webhook-Attempt` | Attempt number, from 1 |

## Environment Variables

```bash
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

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

```

## Reference Materials

* [references/overview.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/schematic-webhooks/references/overview.md) - Schematic webhook concepts and all events
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/schematic-webhooks/references/setup.md) - Configuration guide
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/schematic-webhooks/references/verification.md) - Signature verification details and gotchas