From 0ff2450f526a2b24eee9fac3f4a5616f44eaf4cc Mon Sep 17 00:00:00 2001 From: garethx Date: Thu, 1 Oct 2026 18:43:41 +0100 Subject: [PATCH 1/2] fix: improve polytomic-webhooks skill --- .claude-plugin/marketplace.json | 21 + README.md | 1 + providers.yaml | 125 +++++ skills/polytomic-webhooks/SKILL.md | 331 +++++++++++++ .../examples/express/.env.example | 25 + .../examples/express/README.md | 151 ++++++ .../examples/express/package.json | 18 + .../examples/express/src/index.js | 342 ++++++++++++++ .../examples/express/test/webhook.test.js | 367 +++++++++++++++ .../examples/fastapi/.env.example | 25 + .../examples/fastapi/README.md | 182 +++++++ .../examples/fastapi/main.py | 346 ++++++++++++++ .../examples/fastapi/requirements.txt | 5 + .../examples/fastapi/test_webhook.py | 444 ++++++++++++++++++ .../examples/nextjs/.env.example | 25 + .../examples/nextjs/README.md | 164 +++++++ .../nextjs/app/webhooks/polytomic/route.ts | 324 +++++++++++++ .../examples/nextjs/package.json | 22 + .../examples/nextjs/test/webhook.test.ts | 379 +++++++++++++++ .../examples/nextjs/vitest.config.ts | 8 + .../polytomic-webhooks/references/overview.md | 165 +++++++ skills/polytomic-webhooks/references/setup.md | 209 +++++++++ .../references/verification.md | 307 ++++++++++++ 23 files changed, 3986 insertions(+) create mode 100644 skills/polytomic-webhooks/SKILL.md create mode 100644 skills/polytomic-webhooks/examples/express/.env.example create mode 100644 skills/polytomic-webhooks/examples/express/README.md create mode 100644 skills/polytomic-webhooks/examples/express/package.json create mode 100644 skills/polytomic-webhooks/examples/express/src/index.js create mode 100644 skills/polytomic-webhooks/examples/express/test/webhook.test.js create mode 100644 skills/polytomic-webhooks/examples/fastapi/.env.example create mode 100644 skills/polytomic-webhooks/examples/fastapi/README.md create mode 100644 skills/polytomic-webhooks/examples/fastapi/main.py create mode 100644 skills/polytomic-webhooks/examples/fastapi/requirements.txt create mode 100644 skills/polytomic-webhooks/examples/fastapi/test_webhook.py create mode 100644 skills/polytomic-webhooks/examples/nextjs/.env.example create mode 100644 skills/polytomic-webhooks/examples/nextjs/README.md create mode 100644 skills/polytomic-webhooks/examples/nextjs/app/webhooks/polytomic/route.ts create mode 100644 skills/polytomic-webhooks/examples/nextjs/package.json create mode 100644 skills/polytomic-webhooks/examples/nextjs/test/webhook.test.ts create mode 100644 skills/polytomic-webhooks/examples/nextjs/vitest.config.ts create mode 100644 skills/polytomic-webhooks/references/overview.md create mode 100644 skills/polytomic-webhooks/references/setup.md create mode 100644 skills/polytomic-webhooks/references/verification.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3b1751dd..65ec772f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -524,6 +524,27 @@ "global" ] }, + { + "name": "polytomic-webhooks", + "description": "Receive and authenticate Polytomic webhooks. Use when building a Polytomic Webhook *destination* receiver, because Polytomic does NOT sign its payloads — there is NO signature, NO HMAC and NO signing secret, and the only authentication is a static shared bearer token compared against the connection Secret. Use when the `Polytomic-Signature-Timestamp` header misleads you into writing an HMAC verifier (it carries only an RFC 3339 timestamp, not a digest), when handling the single `sync.records` event, when looping over the `object.records[]` batch and its `hash` / user-defined `fields` keys, when deciding what `object.metadata` may be, or when a non-2xx response makes a Polytomic Model Sync appear as a failure.", + "source": "./skills/polytomic-webhooks", + "strict": false, + "skills": [ + "./" + ], + "category": "integration", + "license": "MIT", + "author": { + "name": "Hookdeck", + "email": "phil@hookdeck.com" + }, + "repository": "https://github.com/hookdeck/webhook-skills", + "homepage": "https://github.com/hookdeck/webhook-skills/tree/main/skills/polytomic-webhooks", + "keywords": [ + "webhooks", + "polytomic" + ] + }, { "name": "praxis-webhooks", "description": "Receive and verify Praxis (Praxis Tech / Cashier payment orchestration) webhooks. Use when setting up a Praxis webhook endpoint, verifying the gt-authentication SHA-384 signature, signing the acknowledgement with the external-request-signature header, or handling Payment Notification (transaction_status pending, approved, rejected, error) and Subscription Notification events.", diff --git a/README.md b/README.md index 761fe910..26b75d4f 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ Skills for receiving and verifying webhooks from specific providers. Each includ | [Paystack](https://paystack.com/docs/payments/webhooks/) | [`paystack-webhooks`](skills/paystack-webhooks/) | Verify Paystack webhook signatures (`x-paystack-signature`, HMAC-SHA512), handle charge, transfer, and subscription events | | [Polar](https://polar.sh/docs/integrate/webhooks/endpoints) | [`polar-webhooks`](skills/polar-webhooks/) | Verify Polar webhook signatures (Standard Webhooks), handle order, subscription, and checkout events | | [Picqer](https://picqer.com/en/api/webhooks) | [`picqer-webhooks`](skills/picqer-webhooks/) | Verify Picqer webhook signatures (`X-Picqer-Signature`, HMAC-SHA256 base64), handle order, picklist, and stock events | +| [Polytomic](https://docs.polytomic.com/docs/webhooks-connections) | [`polytomic-webhooks`](skills/polytomic-webhooks/) | Authenticate Polytomic webhooks (static shared bearer token in `Authorization` — NO signature, NO HMAC; `Polytomic-Signature-Timestamp` is an RFC 3339 timestamp, not a digest), handle the single `sync.records` event and loop over the `object.records[]` batch | | [Postmark](https://postmarkapp.com/developer/webhooks/webhooks-overview) | [`postmark-webhooks`](skills/postmark-webhooks/) | Authenticate Postmark webhooks (Basic Auth/Token), handle email delivery, bounce, open, click, and spam events | | [Praxis](https://docs.praxis.tech/reference/webhooks) | [`praxis-webhooks`](skills/praxis-webhooks/) | Verify Praxis (Cashier) webhook signatures (`gt-authentication`, SHA-384 hex over ordered field values + Merchant Secret — not HMAC, not Standard Webhooks), sign the acknowledgement (`external-request-signature`), handle Payment Notification `transaction_status` (`pending`, `approved`, `rejected`, `error`) and Subscription Notification `event`s | | [Pylon](https://getpylon.com/developers/guides/using-webhooks/) | [`pylon-webhooks`](skills/pylon-webhooks/) | Verify Pylon webhook signatures (`Pylon-Webhook-Signature`, `hs256=` HMAC-SHA256 over `timestamp.body`), handle B2B support issue events like `issue.created` and `issue.updated` | diff --git a/providers.yaml b/providers.yaml index fa639375..7ae01285 100644 --- a/providers.yaml +++ b/providers.yaml @@ -3093,6 +3093,131 @@ providers: sdks: npm: ["@polar-sh/sdk"] pip: ["polar-sdk"] + - name: polytomic + displayName: Polytomic + docs: + webhooks: https://docs.polytomic.com/docs/webhooks-connections + verification: https://docs.polytomic.com/docs/webhooks-connections + events: https://docs.polytomic.com/docs/webhooks-connections + notes: > + IDENTITY: Polytomic (polytomic.com), the data-movement platform (models, Model Syncs, + Bulk Syncs, reverse ETL between warehouses, SaaS apps and databases). Docs are a ReadMe + hub; every page is also retrievable as markdown by appending `.md`. + + VARIANT: Polytomic has exactly ONE outbound-HTTP surface — the Webhook connection used + as a sync DESTINATION ("Webhooks — Destination", the only webhook page in + docs.polytomic.com/llms.txt). You add a Webhook connection, point a Model Sync at it, + and Polytomic POSTs batches of changed records on the sync's schedule. There is no + event-subscription UI, no per-event-type toggles and no platform-events API. Frame the + skill as "receive Polytomic sync record batches", not "subscribe to Polytomic events". + + VERIFICATION: THERE IS NO SIGNATURE. NO HMAC, no digest, no signing secret, no + signature header and no X-Polytomic-* header of any kind. The ONLY authentication is a + STATIC SHARED BEARER TOKEN. Verbatim: "This should be a 'Bearer' token matching the + same value that was provided as the 'Secret' during connection setup. For now, this is + the only request authorization and is a static value." Strip exactly one leading + "Bearer " prefix (case-insensitive on the scheme per RFC 7235), then compare to the + stored secret with crypto.timingSafeEqual / hmac.compare_digest (length-guard first; + timingSafeEqual throws on unequal lengths). Fail CLOSED with 500 if the secret env var + is unset; 401 on mismatch. crypto.createHmac / hashlib must NOT appear in the verify + path. Polytomic publishes no SDK webhook verifier in any language, so manual comparison + is THE implementation, not an SDK fallback. + + THE TRAP: `Polytomic-Signature-Timestamp` IS NOT A SIGNATURE despite its name. It + carries only an RFC 3339 / ISO 8601 UTC timestamp (documented example + "2021-06-01T22:55:36Z") — NOT a Unix epoch integer and NOT a digest. Parse with + new Date(value) / datetime.fromisoformat (normalise the trailing "Z" to "+00:00" for + Python 3.9/3.10), NEVER parseInt/int. The docs endorse a freshness window ("reject + requests older than you expect (more than a few minutes old)") — implement it as an + OPTIONAL configurable tolerance, default ~300s, and state plainly that it is a + staleness check only and proves nothing about authenticity, because the timestamp is + not covered by any signature. Do NOT fabricate an HMAC over timestamp+body. + + DO NOT VERIFY THE TOKEN AS A JWT. The documented sample value decodes to a real HS256 + JWT with claims {"aud":"webhook","jti":"","iss":"https://app.polytomic-local.com:8443/"} + (iss is the issuing Polytomic instance; the example is a local dev host). But it is + signed with a key Polytomic does not give you, carries no `exp`, and the docs call it + "a static value". Treat the whole bearer string as an OPAQUE SECRET and compare + byte-for-byte. jwt.verify/jwt.decode or aud/iss/exp checks either crash or add a false + sense of security. May be mentioned in prose, hedged as "observed in the documented + example" — the docs never describe the token's internal format. + + HEADERS (complete documented list, docs' casing): Content-Type: application/json + ("Polytomic delivers its webhooks payloads as json only. This header will always be + present."); Polytomic-Signature-Timestamp (RFC 3339 UTC); Authorization: Bearer + ; User-Agent (documented example "Polytomic/rel2021.05.25" — illustrative only, + the release suffix changes, never authenticate on it); Content-Length. The docs' sample + request also shows `Accept-Encoding: gzip` while the prose says "Polytomic delivers + payloads as a gzipped response" — these are IN TENSION (Accept-Encoding on a request is + a request header), and it is the one internally inconsistent claim on the page. Report + the operational consequence rather than asserting a header name: the body may arrive + gzip-compressed, most frameworks/proxies decompress transparently, and capturing the + raw body yourself may require inflating it. Hedge it; there is no live capture to + settle it. + + EVENTS: exactly ONE documented event type, `sync.records`. Verbatim: "This is an event + type to help you distinguish new and future hooks. You should only process webhooks you + know about—for right now, that is just the sync.records event." Route on the top-level + `event` field and make the DEFAULT BRANCH IGNORE UNKNOWN EVENTS WITH A 200 — the docs + anticipate future types, and a 4xx marks the sync run failed. DO NOT INVENT event names + (no sync.started, sync.completed, sync.failed, record.created). + + PAYLOAD ENVELOPE: {"event":"sync.records","object":{"id":"1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name":"Webhook HTTP Endpoint sync","records":[{"hash":"b7421c6c57bd49f7","fields": + {"email":"nathan@polytomic.com","last_login":"2020-12-02T00:00:00Z"}}],"metadata":{}}}. + `object` is "an envelope that will contain the payload, regardless of event" and is + always present. `object.id` is the UUID of the SYNC, matching the id in the Polytomic UI + URL bar. `object.name` is the sync's name, "useful for discriminating against data + coming in from different endpoints". `object.records` is "a list of the records changed + since the last payload" — THE PAYLOAD IS A BATCH, NOT A SINGLE RECORD; every handler + example must loop; default batch size 100 ("Webhook batch size (default: 100)") and + user-configurable, so never assume 1 and tolerate large batches. + `object.records[].hash` is "a computed hash of the record's fields key/values pairs, + which may be useful for deduplicating incoming data" — use as an idempotency key; do + NOT describe its algorithm or length (the docs don't; b7421c6c57bd49f7 is just an + example); never use it for authentication. `object.records[].fields` "contains each of + the fields you selected to be delivered" — THE KEYS ARE USER-DEFINED by the sync + configuration; email/last_login are that customer's choices, not a Polytomic schema, so + do not typed-model them as a fixed shape and show defensive access. + `object.metadata` is "Any key-value pairs of metadata defined in the sync + configuration" (Advanced settings → Metadata, default null) — it may be an object, null + OR ABSENT; handle all three. + + RESPONSE CONTRACT: verbatim "On receipt of the payload, your API should return 200 OK. + Any 4xx or 5xx error will cause the sync to appear as a failure." There is NO documented + automatic retry policy and NO retry schedule — do not claim exponential backoff or a + retry count. A non-2xx marks the sync run failed in Polytomic's sync history; recovery + is operational (fix the endpoint, re-run or wait for the next schedule). Because failure + is sync-level, acknowledge fast (200) and process the batch asynchronously. + + SETUP (verbatim): Connections → Add Connection → Webhook; enter the delivery URL; + "Polytomic will give you a secret key that you will be able to use to verify incoming + payloads with. Hovering over the secret key field will reveal its value." NO handshake, + challenge or validation request is documented on webhook creation. + + ADVANCED SETTINGS that change delivery semantics: "Always do a full sync" (default + false — Model Syncs are differential by default, so `records` normally holds only + changes; on means every run re-delivers everything); "Skip backfill on first sync" + (default false — the FIRST run delivers the entire source, the single most common cause + of an unexpectedly huge first batch); "Capture webhook requests and responses" (default + true — request/response log in the Polytomic sync history view, the debugging surface to + point readers at); "Webhook batch size" (default 100); "Metadata" (default null). + + SOURCE IPs: the webhooks page links to https://docs.polytomic.com/docs/whitelist-ips — + 54.190.82.25, 44.232.40.21, 35.155.106.54, 54.200.67.134, 44.224.213.129, 54.149.95.139. + Caveat honestly: that page frames the list as the IPs for "integrating Polytomic with + databases, data warehouses, and cloud storage" and the webhooks page merely links to it + for webhook traffic — present it as "the allowlist Polytomic points you at", a firewall + convenience and NOT an authentication mechanism, and note it does not apply to + self-hosted/on-premise Polytomic deployments (stated on that page). + + DO NOT FABRICATE: no signature algorithm, no signing secret separate from the bearer + secret, no X-Polytomic-* headers, no event types beyond sync.records, no fixed `fields` + schema, no retry policy, no documented payload size limit, no handshake/challenge, no + HMAC. Hedge or omit anything not sourceable to the two doc URLs above. + testScenario: + events: + - sync.records - name: picqer displayName: Picqer docs: diff --git a/skills/polytomic-webhooks/SKILL.md b/skills/polytomic-webhooks/SKILL.md new file mode 100644 index 00000000..28c02226 --- /dev/null +++ b/skills/polytomic-webhooks/SKILL.md @@ -0,0 +1,331 @@ +--- +name: polytomic-webhooks +description: > + Receive and authenticate Polytomic webhooks. Use when building a Polytomic + Webhook *destination* receiver, because Polytomic does NOT sign its payloads — + there is NO signature, NO HMAC and NO signing secret, and the only + authentication is a static shared bearer token compared against the connection + Secret. Use when the `Polytomic-Signature-Timestamp` header misleads you into + writing an HMAC verifier (it carries only an RFC 3339 timestamp, not a digest), + when handling the single `sync.records` event, when looping over the + `object.records[]` batch and its `hash` / user-defined `fields` keys, when + deciding what `object.metadata` may be, or when a non-2xx response makes a + Polytomic Model Sync appear as a failure. +license: MIT +metadata: + author: hookdeck + version: "0.1.0" + repository: https://github.com/hookdeck/webhook-skills +--- + +# Polytomic Webhooks + +**Polytomic** (polytomic.com) is a data-movement platform — models, Model Syncs +and Bulk Syncs that move data between warehouses, SaaS apps and databases +(reverse ETL). Its one outbound-HTTP surface is the **Webhook connection used as +a sync destination**: you add a Webhook *connection*, point a Model Sync at it, +and Polytomic POSTs **batches of changed records** to your URL on the sync's +schedule. + +**Frame this as "receive Polytomic sync record batches", not "subscribe to +Polytomic events."** There is no event-subscription UI, no per-event-type +toggles and no separate platform-events API. Polytomic is not an event producer +in the usual sense. + +**Three things make Polytomic unlike most providers in this repo:** + +1. **There is no signature.** No HMAC, no digest, no signing secret. The *only* + authentication is a **static shared bearer token** in the `Authorization` + header, matching the Secret shown during connection setup. Verbatim from the + docs: *"This should be a 'Bearer' token matching the same value that was + provided as the 'Secret' during connection setup. For now, this is the only + request authorization and is a static value."* +2. **`Polytomic-Signature-Timestamp` is NOT a signature**, despite the name. It + carries only an **RFC 3339 / ISO 8601 UTC timestamp** (`2021-06-01T22:55:36Z`) + — not a Unix epoch integer and not a digest. Parse it with `new Date(value)` / + `datetime.fromisoformat`, never `parseInt`. +3. **Every payload is a batch.** `object.records` is *"a list of the records + changed since the last payload"* — default batch size **100**, and + user-configurable. Every handler must loop. Never assume one record. + +## When to Use This Skill + +- How do I receive Polytomic webhooks? +- How do I verify a Polytomic webhook signature? (**You can't — there is none. + Compare the bearer token instead.**) +- What do I do with the `Polytomic-Signature-Timestamp` header? (**Freshness + only — it is not a signature.**) +- Should I HMAC the timestamp plus the body? (**No. There is nothing to compare + an HMAC against.**) +- Is the `Authorization` bearer token a JWT I should verify? (**No — treat it as + an opaque secret and compare byte-for-byte.**) +- Which Polytomic webhook event types exist? (**Exactly one: `sync.records`.**) +- How do I loop over `object.records[]` and use `hash` for deduplication? +- Why are the keys inside `fields` different from the docs' example? (**They are + defined by *your* sync configuration.**) +- Why did my Polytomic sync show as failed? (**Your endpoint returned 4xx/5xx.**) +- Why was my first webhook batch enormous? (**The first sync run backfills + everything.**) + +## Verification (core): compare the bearer token, constant-time + +`crypto.createHmac` / `hashlib` must **not** appear anywhere in this path — +there is no signature to compute and nothing to compare a digest against. + +```javascript +const crypto = require('crypto'); + +// Polytomic does NOT sign webhooks. The ONLY authentication is the static +// shared bearer token you were given as the connection "Secret". +function verifyBearerToken(authorizationHeader, secret) { + if (!secret) return null; // unset => caller MUST fail closed (500) + if (typeof authorizationHeader !== 'string') return false; + + // Strip exactly ONE leading "Bearer " prefix. The scheme is case-insensitive + // per RFC 7235; the token after it is not. + const token = authorizationHeader.replace(/^Bearer /i, ''); + + // Treat the token as an OPAQUE SECRET. The docs' example value happens to + // decode as an HS256 JWT, but you do not hold its signing key and it has no + // `exp` — calling jwt.verify()/jwt.decode() or checking aud/iss/exp either + // throws or adds a false sense of security. Compare byte-for-byte. + const a = Buffer.from(token); + const b = Buffer.from(secret); + // Length-guard first: crypto.timingSafeEqual throws on unequal lengths. + return a.length === b.length && crypto.timingSafeEqual(a, b); +} +``` + +> **For complete handlers with tests**, see [examples/express/](examples/express/), [examples/nextjs/](examples/nextjs/), [examples/fastapi/](examples/fastapi/). + +**Fail closed.** If `POLYTOMIC_WEBHOOK_SECRET` is unset, return **500** and log +loudly — never silently accept. On a provider with no signature, a missing +secret is the entire security boundary gone. Return **401** on mismatch. + +### Timestamp freshness is defence-in-depth only + +The docs endorse a staleness check — *"In general, it is a good idea to reject +requests older than you expect (more than a few minutes old)"* — and the +examples implement it as an **optional, configurable tolerance (default 300s)**. + +But be clear about what it buys you: **it proves nothing about authenticity.** +The timestamp is not covered by any signature, so an attacker holding the bearer +token can set any value they like. It defends against replay of an *old captured +request*, nothing more. + +See [references/verification.md](references/verification.md) for the full +rationale, the RFC 3339 parsing gotcha, and why the token is not a JWT. + +## Headers + +Complete documented list, in the docs' casing. **There is no `X-Polytomic-*` +header and no signature header.** + +| Header | Value | Notes | +|--------|-------|-------| +| `Authorization` | `Bearer ` | The connection Secret. **The only authentication.** | +| `Polytomic-Signature-Timestamp` | `2021-06-01T22:55:36Z` | **RFC 3339 UTC timestamp, NOT a digest.** Freshness checks only. | +| `Content-Type` | `application/json` | *"Polytomic delivers its webhooks payloads as json only. This header will always be present."* | +| `User-Agent` | `Polytomic/rel2021.05.25` | Illustrative only — the release suffix changes. **Never authenticate on it.** | +| `Content-Length` | e.g. `646` | Ordinary. | + +**On gzip:** the docs' sample request shows an `Accept-Encoding: gzip` line while +the prose says *"Polytomic delivers payloads as a gzipped response to minimize +bandwidth use. Your client likely supports decoding this automatically."* Those +two statements are in tension — `Accept-Encoding` on a *request* is a request +header, so it would not be what carries response compression. Rather than assert +a header we have not observed, take the operational consequence: **the body may +arrive gzip-compressed**, most frameworks and reverse proxies decompress it +transparently, and **if you capture the raw body yourself you may need to inflate +it**. This is the one internally inconsistent claim on the page; treat it as +unsettled until you capture a real delivery. + +## The Payload + +Documented envelope, verbatim: + +```json +{ + "event": "sync.records", + "object": { + "id": "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name": "Webhook HTTP Endpoint sync", + "records": [ + { + "hash": "b7421c6c57bd49f7", + "fields": { + "email": "nathan@polytomic.com", + "last_login": "2020-12-02T00:00:00Z" + } + } + ], + "metadata": { } + } +} +``` + +| Field | Type | Notes | +|-------|------|-------| +| `event` | string | The event-type discriminator. **Only `sync.records` is documented.** | +| `object` | object | *"an envelope that will contain the payload, regardless of event."* Always present. | +| `object.id` | string (UUID) | The **sync's** id — *"It will match the value seen the URL bar when you have the corresponding sync configuration open."* | +| `object.name` | string | The sync's name — *"useful for discriminating against data coming in from different endpoints."* | +| `object.records` | array | *"a list of the records changed since the last payload."* **A BATCH.** Default 100, configurable. | +| `object.records[].hash` | string | *"a computed hash of the record's fields key/values pairs, which may be useful for deduplicating incoming data."* Use as an **idempotency key**. | +| `object.records[].fields` | object | *"contains each of the fields you selected to be delivered."* **Keys are user-defined.** | +| `object.metadata` | object \| null \| absent | *"Any key-value pairs of metadata defined in the sync configuration."* Default `null`. | + +**`fields` has no fixed schema.** The `email` / `last_login` in the example are +that customer's selected fields, not a Polytomic schema. Do **not** type a +model against them — access defensively and tolerate missing keys. + +**Do not describe `hash`'s algorithm or length.** The docs don't, and +`b7421c6c57bd49f7` is just an example. Never use it for authentication. + +## Events + +**Exactly one documented event type: `sync.records`.** Verbatim: *"This is an +event type to help you distinguish new and future hooks. You should only process +webhooks you know about—for right now, that is just the sync.records event."* + +| Event | Fires when | Payload | +|-------|-----------|---------| +| `sync.records` | A Model Sync run delivers records changed since the last payload | `object.records[]` batch | + +Route on the top-level `event` field, and make the **default branch ignore +unknown events with a 200** — the docs explicitly anticipate future types. +Erroring on an unknown event would mark the customer's sync as failed. + +**Do not invent event names.** There is no `sync.started`, `sync.completed`, +`sync.failed` or `record.created`. None are documented. + +## Response Contract + +Verbatim: *"On receipt of the payload, your API should return `200 OK`. Any 4xx +or 5xx error will cause the sync to appear as a failure."* + +- **There is no documented retry policy and no documented retry schedule.** Do + not assume exponential backoff or a retry count — none is published. +- A non-2xx marks the **sync run** failed in Polytomic's sync history. Recovery + is operational: fix the endpoint, then re-run or wait for the next schedule. +- Because failure is **sync-level**, the usual advice applies with extra force: + **acknowledge with 200 fast and process the batch asynchronously**, so a slow + downstream doesn't fail your customer's sync. + +## Environment Variables + +```bash +# REQUIRED. The connection Secret from Polytomic (Connections → your Webhook +# connection; hover the secret key field to reveal it). Polytomic sends it back +# verbatim as `Authorization: Bearer `. This is NOT a signing secret — +# there is no HMAC. Unset => the handler fails closed with 500. +POLYTOMIC_WEBHOOK_SECRET= + +# OPTIONAL. Freshness window for Polytomic-Signature-Timestamp, in seconds. +# Default 300 (the docs suggest "more than a few minutes old"). Set to 0 to +# disable. DEFENCE-IN-DEPTH ONLY — the timestamp is not signed, so this proves +# nothing about authenticity. +POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=300 +``` + +## Setup + +Verbatim steps from the docs: + +1. In Polytomic, go to **Connections** → **Add Connection** → **Webhook**. +2. Enter the URL that you'd like Polytomic to deliver payloads to. +3. *"Polytomic will give you a secret key that you will be able to use to verify + incoming payloads with. Hovering over the secret key field will reveal its + value."* + +Then create a Model Sync with your chosen fields, targeting that Webhook +connection. **No handshake, challenge or validation request is documented** — +Polytomic does not ping your URL to confirm it on creation. + +See [references/setup.md](references/setup.md) for the Advanced settings that +change delivery semantics (batch size, metadata, full sync, backfill skip, +request capture). + +## Advanced Settings That Change Delivery + +Found at the bottom of the sync configuration: + +- **Webhook batch size (default: 100)** — how many records per POST. Handlers + must not assume 1, and should tolerate large batches. +- **Metadata (default: null)** — hardcoded key/values echoed into + `object.metadata`. This is why `metadata` may be absent or `null`. +- **Always do a full sync (default: false)** — Model Syncs are **differential by + default**, so `records` normally holds only changes. Turning this on + re-delivers everything on every run. +- **Skip backfill on first sync (default: false)** — *the first sync run + delivers the entire source.* **This is the single most common cause of an + unexpectedly huge first batch.** +- **Capture webhook requests and responses (default: true)** — a request/response + log in the Polytomic **sync history** view. This is your debugging surface. + +## Source IPs + +The webhooks page points you at +[docs.polytomic.com/docs/whitelist-ips](https://docs.polytomic.com/docs/whitelist-ips) +if your API sits behind a firewall. Those addresses are `54.190.82.25`, +`44.232.40.21`, `35.155.106.54`, `54.200.67.134`, `44.224.213.129` and +`54.149.95.139`. + +Two honest caveats: that page frames the list as the IPs for *"integrating +Polytomic with databases, data warehouses, and cloud storage"* (the webhooks page +merely links to it for webhook traffic), and it **does not apply to self-hosted / +on-premise Polytomic deployments** — stated on that page. Treat it as **a +firewall convenience Polytomic points you at, not an authentication mechanism.** + +## Local Development + +```bash +npx hookdeck-cli listen 3000 polytomic --path /webhooks/polytomic +``` + +No account required — the CLI creates a guest account on first run and gives you +a public HTTPS URL plus a web UI for inspecting requests. Paste that URL into the +Webhook connection and run your sync. + +Because Polytomic has no signature, there is nothing for a gateway to verify on +the **Polytomic → gateway** hop. What Hookdeck adds is an unguessable source URL, +retries and replay that Polytomic itself does not document, plus its own outbound +signature on the **gateway → your destination** hop. + +## Reference Materials + +- [references/overview.md](references/overview.md) - What a Polytomic Webhook destination is, the single `sync.records` event, the batch envelope field-by-field, why `fields` keys are user-defined, and the response contract +- [references/setup.md](references/setup.md) - Creating the Webhook connection, revealing the Secret, pointing a Model Sync at it, every Advanced setting, the IP allowlist caveats, and the sync-history debugging surface +- [references/verification.md](references/verification.md) - Why there is no signature, the constant-time bearer comparison, the `Polytomic-Signature-Timestamp` trap, why the token is not a JWT, the gzip ambiguity, and debugging 401s + +## Attribution + +When using this skill, add this comment at the top of generated files: + +```javascript +// Generated with: polytomic-webhooks skill +// https://github.com/hookdeck/webhook-skills +``` + +## Recommended: webhook-handler-patterns + +We recommend installing the [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) skill alongside this one for handler sequence, idempotency, error handling, and retry logic. Key references (open on GitHub): + +- [Handler sequence](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/handler-sequence.md) — Validate first, dispatch second, handle idempotently third +- [Idempotency](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/idempotency.md) — Prevent duplicate processing (dedupe on `object.id` + `records[].hash`) +- [Error handling](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/error-handling.md) — Return codes, logging, dead letter queues — and why a 4xx/5xx fails the whole Polytomic sync run +- [Retry logic](https://github.com/hookdeck/webhook-skills/blob/main/skills/webhook-handler-patterns/references/retry-logic.md) — Provider retry schedules and backoff patterns (Polytomic documents none) + +## Related Skills + +- [baselinker-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/baselinker-webhooks) - Another provider with no signature at all — the closest comparison for "what do I do instead of verifying?" +- [docker-hub-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/docker-hub-webhooks) - Unsigned webhooks secured with a secret URL token and a fail-closed check +- [pipedrive-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/pipedrive-webhooks) - Authenticated with HTTP Basic Auth rather than a signature — the same "credential, not digest" shape +- [postmark-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/postmark-webhooks) - Basic Auth / token authentication instead of signing +- [svix-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/svix-webhooks) - Standard Webhooks signing, for contrast with what Polytomic lacks +- [salesforce-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/salesforce-webhooks) - A very common Polytomic Model Sync source and destination +- [hubspot-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/hubspot-webhooks) - Another SaaS system Polytomic syncs to and from +- [supabase-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/supabase-webhooks) - Database row-change webhooks — the same "changed records" shape from the database side +- [airtable-webhooks](https://github.com/hookdeck/webhook-skills/tree/main/skills/airtable-webhooks) - Change-feed webhooks that also require batch handling +- [webhook-handler-patterns](https://github.com/hookdeck/webhook-skills/tree/main/skills/webhook-handler-patterns) - Handler sequence, idempotency, error handling, retry logic +- [hookdeck-event-gateway](https://github.com/hookdeck/webhook-skills/tree/main/skills/hookdeck-event-gateway) - Webhook infrastructure that replaces your queue — guaranteed delivery, automatic retries, replay, rate limiting, and observability for your webhook handlers diff --git a/skills/polytomic-webhooks/examples/express/.env.example b/skills/polytomic-webhooks/examples/express/.env.example new file mode 100644 index 00000000..57219043 --- /dev/null +++ b/skills/polytomic-webhooks/examples/express/.env.example @@ -0,0 +1,25 @@ +# REQUIRED. The connection Secret from Polytomic. +# +# Connections -> your Webhook connection -> hover the secret key field to reveal +# its value. Polytomic sends this exact value back on every request as +# Authorization: Bearer +# +# This is NOT a signing secret. Polytomic does not sign its webhooks — there is +# no HMAC, no digest and no signature header. The bearer token is the ONLY +# authentication. If this is unset the handler fails closed with 500. +# +# The value may look like a JWT (the docs' example does). Treat it as an opaque +# string: do not decode it, do not jwt.verify() it. +POLYTOMIC_WEBHOOK_SECRET= + +# OPTIONAL. Freshness window for the Polytomic-Signature-Timestamp header, in +# seconds. Default 300 (the docs suggest rejecting requests "more than a few +# minutes old"). Set to 0 to disable the check entirely. +# +# DEFENCE-IN-DEPTH ONLY. That header is an RFC 3339 timestamp, NOT a signature, +# and it is not covered by any signature — so this proves nothing about +# authenticity. It only limits replay of an old captured request. +POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=300 + +# Optional. Port for the example server. +PORT=3000 diff --git a/skills/polytomic-webhooks/examples/express/README.md b/skills/polytomic-webhooks/examples/express/README.md new file mode 100644 index 00000000..235f11e5 --- /dev/null +++ b/skills/polytomic-webhooks/examples/express/README.md @@ -0,0 +1,151 @@ +# Polytomic Webhooks - Express Example + +Minimal example of receiving **Polytomic sync record batches** in Express. + +> **There is no signature to verify.** Polytomic does not sign its webhook +> payloads — no HMAC, no digest, no signing secret, no signature header, no +> `X-Polytomic-*` header of any kind. The **only** authentication is a **static +> shared bearer token** matching the connection Secret. See the skill's +> [references/verification.md](../../references/verification.md). + +> **`Polytomic-Signature-Timestamp` is not a signature.** Despite the name, it +> carries only an RFC 3339 UTC timestamp (`2021-06-01T22:55:36Z`). It is used here +> for an optional freshness check, which is **defence-in-depth only** — the +> timestamp is not covered by any signature, so it proves nothing about +> authenticity. + +## What this example shows + +- `app.post('/webhooks/polytomic', ...)` — authenticating by comparing the + `Authorization` bearer token against `POLYTOMIC_WEBHOOK_SECRET` in **constant + time**, stripping exactly one `Bearer ` prefix (case-insensitively on the + scheme, per RFC 7235) and length-guarding so `crypto.timingSafeEqual` can't + throw. +- **Fail closed:** if `POLYTOMIC_WEBHOOK_SECRET` is unset the route returns `500` + and processes nothing. On a provider with no signature, the bearer token is the + *entire* security boundary — "unconfigured" must never mean "accept everything". +- **No HMAC anywhere.** `crypto.createHmac` / `createHash` are not called, and a + test asserts that. There is no signature to compute a digest against. +- **The bearer token is treated as an opaque secret.** The documented sample value + happens to decode as an HS256 JWT, but it is signed with a key you don't hold, + has no `exp`, and the docs call it "a static value" — so no `jwt.verify`, no + `jwt.decode`, no `aud`/`iss`/`exp` checks. A test proves a non-JWT secret works. +- **Optional timestamp freshness** (`POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS`, + default 300, `0` disables) parsed with `new Date()` — **never `parseInt`**, which + would turn `2021-06-01T22:55:36Z` into `2021`. A test asserts an epoch-seconds + value is *not* accepted. +- **Batch handling.** `object.records` is a list (default size 100, + user-configurable), so the handler **always loops**. Tested with a 250-record + batch and an empty one. +- **`records[].fields` keys are user-defined** by the sync configuration — not + typed as a fixed shape, accessed defensively, `null` values tolerated. +- **`object.metadata` may be an object, `null`, or absent** — all three tested. +- **Event dispatch on the single documented event, `sync.records`**, with the + default branch **ignoring unknown events with a 200** (the docs anticipate + future types, and a 4xx would mark the customer's sync run as failed). +- **Acknowledge-then-process:** the `200` is sent before the batch is handled, + because a 4xx/5xx makes the whole sync run appear as a failure. + +Because there is no signature, there is **no raw-body requirement** here — +`express.json()` is fine. (Polytomic may gzip the body; Express and your reverse +proxy decompress that transparently.) Malformed JSON becomes a `400`. + +## Prerequisites + +- Node.js 18+ +- A Polytomic account with a **Webhook connection** and a **Model Sync** pointed + at it. A connection alone delivers nothing — the sync is what sends. + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add your Polytomic connection Secret to `.env` as + `POLYTOMIC_WEBHOOK_SECRET`. In Polytomic: **Connections** → your Webhook + connection → **hover the secret key field to reveal its value**. + + There is **no signing secret** to configure — Polytomic has none. This value + is the shared bearer token Polytomic sends back on every request. + +## Run + +```bash +npm start +``` + +Server runs on http://localhost:3000 + +## Test + +```bash +npm test +``` + +Replay the documented payload by hand: + +```bash +curl -X POST http://localhost:3000/webhooks/polytomic \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $POLYTOMIC_WEBHOOK_SECRET" \ + -H "Polytomic-Signature-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -d '{ + "event": "sync.records", + "object": { + "id": "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name": "Webhook HTTP Endpoint sync", + "records": [ + { + "hash": "b7421c6c57bd49f7", + "fields": { "email": "nathan@polytomic.com", "last_login": "2020-12-02T00:00:00Z" } + } + ], + "metadata": {} + } + }' +``` + +Note the timestamp format: **RFC 3339 UTC**, not a Unix epoch integer. + +Polytomic documents **no test event, no handshake and no "send test" button** — to +exercise the real path you must run the sync. Watch the delivery in Polytomic's +**sync history** view (Advanced settings → *Capture webhook requests and +responses*, default on). + +### Receive webhooks locally + +```bash +npx hookdeck-cli listen 3000 polytomic --path /webhooks/polytomic +``` + +No account required — the CLI creates a guest account on first run and gives you +a public HTTPS URL plus a web UI for inspecting requests. Paste that URL into the +Webhook connection, then run your sync. + +Because Polytomic sends no signature, there is nothing for a gateway to verify on +the **Polytomic → gateway** hop. What a gateway adds here is retries and replay +that Polytomic itself does not document — which matters, because a single 5xx +marks the entire sync run failed. + +## Endpoint + +- `POST /webhooks/polytomic` — `500` if `POLYTOMIC_WEBHOOK_SECRET` is unset (fail + closed), `401` on a missing or mismatched bearer token, `400` on a stale or + unparseable `Polytomic-Signature-Timestamp` (when the tolerance is > 0), `400` + on invalid JSON or a malformed envelope, `200` otherwise — including for + unknown event types, which are ignored. +- `GET /health` — health check. + +## A note on the first delivery + +If your first batch is enormous, that is not a bug: *"The first time a Polytomic +Model Sync runs, it will sync everything in the source."* Either enable **Skip +backfill on first sync** in the sync's Advanced settings, or rely on the +acknowledge-then-process pattern this example uses. diff --git a/skills/polytomic-webhooks/examples/express/package.json b/skills/polytomic-webhooks/examples/express/package.json new file mode 100644 index 00000000..60c2a763 --- /dev/null +++ b/skills/polytomic-webhooks/examples/express/package.json @@ -0,0 +1,18 @@ +{ + "name": "polytomic-webhooks-express", + "version": "1.0.0", + "description": "Polytomic sync record batch webhook handler with Express (no signature — static bearer token)", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "test": "jest" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^5.2.1" + }, + "devDependencies": { + "jest": "^30.5.2", + "supertest": "^7.3.0" + } +} diff --git a/skills/polytomic-webhooks/examples/express/src/index.js b/skills/polytomic-webhooks/examples/express/src/index.js new file mode 100644 index 00000000..08a2cdff --- /dev/null +++ b/skills/polytomic-webhooks/examples/express/src/index.js @@ -0,0 +1,342 @@ +// Generated with: polytomic-webhooks skill +// https://github.com/hookdeck/webhook-skills +require('dotenv').config(); +const express = require('express'); +const crypto = require('crypto'); + +// Polytomic Webhook destination receiver. +// +// Polytomic is a data-movement platform. Its one outbound-HTTP surface is the +// Webhook *connection used as a sync destination*: you add a Webhook connection, +// point a Model Sync at it, and Polytomic POSTs BATCHES OF CHANGED RECORDS to +// your URL on the sync's schedule. This is not event subscription — there is no +// event-subscription UI and no per-event-type toggles. +// +// Three things make Polytomic unlike most providers: +// +// 1. THERE IS NO SIGNATURE. No HMAC, no digest, no signing secret, no +// signature header, and no X-Polytomic-* header of any kind. The ONLY +// authentication is a STATIC SHARED BEARER TOKEN matching the connection +// Secret. Verbatim from the docs: "This should be a 'Bearer' token matching +// the same value that was provided as the 'Secret' during connection setup. +// For now, this is the only request authorization and is a static value." +// Note: crypto.createHmac / createHash appear NOWHERE in this file. There +// is nothing to HMAC and nothing to compare a digest against. +// +// 2. `Polytomic-Signature-Timestamp` IS NOT A SIGNATURE, despite its name. It +// carries only an RFC 3339 / ISO 8601 UTC timestamp ("2021-06-01T22:55:36Z") +// — not a Unix epoch integer and not a digest. Parse it with `new Date()`, +// NEVER parseInt(). We use it for an optional freshness check, which is +// defence-in-depth only: the timestamp is not covered by any signature, so +// an attacker holding the bearer token can set any value they like. +// +// 3. EVERY PAYLOAD IS A BATCH. `object.records` is "a list of the records +// changed since the last payload" — default batch size 100, and +// user-configurable. We always loop. Never assume one record. +// +// Because there is no signature, there is NO raw-body requirement here — +// express.json() is fine. (Polytomic may gzip the body; Express/your proxy +// decompresses that transparently.) + +// REQUIRED. The connection Secret. Read from the environment per request rather +// than captured at module load, so the fail-closed path stays testable and a +// restart isn't needed after a rotation. +const webhookSecret = () => process.env.POLYTOMIC_WEBHOOK_SECRET || ''; +if (!webhookSecret()) { + console.warn( + 'POLYTOMIC_WEBHOOK_SECRET is not set — the webhook route will fail closed ' + + 'with 500. Polytomic does not sign its payloads, so this shared bearer ' + + 'token is the ONLY authentication available. Reveal it by hovering the ' + + 'secret key field on the Webhook connection in Polytomic.' + ); +} + +// OPTIONAL. Freshness tolerance in seconds for Polytomic-Signature-Timestamp. +// Default 300 ("more than a few minutes old", per the docs). 0 disables. +const DEFAULT_TOLERANCE_SECONDS = 300; +function toleranceSeconds() { + const raw = process.env.POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS; + if (raw === undefined || raw === '') return DEFAULT_TOLERANCE_SECONDS; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : DEFAULT_TOLERANCE_SECONDS; +} + +const app = express(); + +/** + * Authenticate the request by comparing the Authorization bearer token against + * the connection Secret, in constant time. + * + * This is the WHOLE of Polytomic's authentication. There is no signature to + * verify: no HMAC is computed here, because no signature header is sent. + * + * The documented sample token happens to decode as an HS256 JWT (claims + * {"aud":"webhook","jti":"","iss":"https://app.polytomic-local.com:8443/"} + * — `iss` is the issuing Polytomic instance, and the docs' example is a local dev + * host). Do NOT treat it as a JWT: it is signed with a key Polytomic does not + * give you, it carries no `exp`, and the docs call it "a static value". Calling + * jwt.verify()/jwt.decode() or checking aud/iss/exp would either throw or add a + * false sense of security. Compare the whole string byte-for-byte. + * + * @param {unknown} authorizationHeader - the raw Authorization header value + * @param {string} secret - POLYTOMIC_WEBHOOK_SECRET + * @returns {boolean|null} null when unconfigured — the caller MUST fail closed + */ +function verifyBearerToken(authorizationHeader, secret) { + if (!secret) return null; // unset => fail closed, never silently accept + if (typeof authorizationHeader !== 'string') return false; + + // Strip exactly ONE leading "Bearer " prefix. The scheme is case-insensitive + // per RFC 7235; the token after it is not. An anchored, non-global replacement + // strips at most one prefix, so a token that itself starts with "Bearer" + // survives intact. + const token = authorizationHeader.replace(/^Bearer /i, ''); + + const a = Buffer.from(token); + const b = Buffer.from(secret); + // Length-guard first: crypto.timingSafeEqual throws on unequal buffer lengths, + // and the attacker controls the incoming length. + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +/** + * Optional freshness check on Polytomic-Signature-Timestamp. + * + * The docs endorse this: "In general, it is a good idea to reject requests older + * than you expect (more than a few minutes old)." But be clear about what it + * buys you — the timestamp is NOT covered by any signature, so this proves + * nothing about authenticity. It only limits replay of an OLD CAPTURED REQUEST. + * + * The value is RFC 3339 UTC, e.g. "2021-06-01T22:55:36Z". NEVER parseInt() it: + * parseInt("2021-06-01T22:55:36Z") returns 2021, which looks like a plausible + * number and silently breaks the check. + * + * @param {unknown} timestampHeader - the raw Polytomic-Signature-Timestamp value + * @param {number} tolerance - seconds; <= 0 disables the check + * @returns {boolean} + */ +function timestampIsFresh(timestampHeader, tolerance = toleranceSeconds()) { + if (tolerance <= 0) return true; // check disabled + if (typeof timestampHeader !== 'string' || timestampHeader.trim() === '') return false; + + const sent = new Date(timestampHeader.trim()); + if (Number.isNaN(sent.getTime())) return false; + + return Math.abs(Date.now() - sent.getTime()) <= tolerance * 1000; +} + +/** + * Defensive validation of the documented envelope. + * + * Documented shape: + * { "event": "sync.records", + * "object": { "id": "", "name": "", + * "records": [ { "hash": "...", "fields": { ... } } ], + * "metadata": { } } } + * + * `object` is "an envelope that will contain the payload, regardless of event" + * and is always present. `metadata` defaults to null in the sync configuration, + * so it may be an object, null, OR ABSENT — all three are handled. + * + * @param {unknown} payload + * @returns {{ok: true, event: string, syncId: string|null, syncName: string|null, + * records: unknown[], metadata: object|null} + * | {ok: false, error: string}} + */ +function parseEnvelope(payload) { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return { ok: false, error: 'payload is not a JSON object' }; + } + if (typeof payload.event !== 'string' || payload.event === '') { + return { ok: false, error: 'missing or invalid event' }; + } + + const object = payload.object; + if (typeof object !== 'object' || object === null || Array.isArray(object)) { + return { ok: false, error: 'missing or invalid object envelope' }; + } + + // `records` is absent on event types we don't know about yet, so it is only + // required for sync.records — validated by the route after dispatch. + if (object.records !== undefined && !Array.isArray(object.records)) { + return { ok: false, error: 'object.records is not an array' }; + } + + return { + ok: true, + event: payload.event, + // The UUID of the SYNC (it matches the id in the Polytomic UI's URL bar for + // that sync config) — not of the delivery or the record. + syncId: typeof object.id === 'string' ? object.id : null, + // The sync's name: "useful for discriminating against data coming in from + // different endpoints." + syncName: typeof object.name === 'string' ? object.name : null, + records: Array.isArray(object.records) ? object.records : [], + // Absent / null / object all collapse to null-or-object here. + metadata: + typeof object.metadata === 'object' && + object.metadata !== null && + !Array.isArray(object.metadata) + ? object.metadata + : null, + }; +} + +/** + * Normalize one record from the batch. + * + * `fields` "contains each of the fields you selected to be delivered" — THE KEYS + * ARE USER-DEFINED by the sync configuration. The `email` / `last_login` in the + * docs' example are that customer's chosen fields, NOT a Polytomic schema. So + * this is deliberately NOT modelled as a fixed shape; access it defensively. + * + * `hash` is "a computed hash of the record's fields key/values pairs, which may + * be useful for deduplicating incoming data" — i.e. an idempotency key. Its + * algorithm and length are undocumented, so never recompute or assume them, and + * NEVER use it for authentication: it is a digest over data Polytomic is sending + * you, computed by Polytomic. + * + * @param {unknown} record + * @returns {{hash: string|null, fields: Record}|null} null if unusable + */ +function normalizeRecord(record) { + if (typeof record !== 'object' || record === null || Array.isArray(record)) return null; + + const fields = + typeof record.fields === 'object' && record.fields !== null && !Array.isArray(record.fields) + ? record.fields + : {}; + + return { + hash: typeof record.hash === 'string' && record.hash !== '' ? record.hash : null, + fields, + }; +} + +/** + * Handle the sync.records batch. + * + * ALWAYS LOOPS. Default batch size is 100 ("Webhook batch size (default: 100)" + * under the sync's Advanced settings) and it is user-configurable, so never + * assume one record and write for large batches. + * + * In production, do this work ASYNCHRONOUSLY — the route acknowledges with 200 + * first. A 4xx/5xx "will cause the sync to appear as a failure", so a slow + * downstream would fail your customer's whole sync run. + * + * @param {ReturnType & {ok: true}} envelope + */ +function handleSyncRecords(envelope) { + console.log( + `Polytomic sync.records: sync "${envelope.syncName ?? 'unknown'}" ` + + `(${envelope.syncId ?? 'no id'}) delivered ${envelope.records.length} record(s)` + ); + + if (envelope.metadata) { + // Hardcoded key/values from Advanced settings -> Metadata (default: null). + console.log(` metadata: ${JSON.stringify(envelope.metadata)}`); + } + + for (const raw of envelope.records) { + const record = normalizeRecord(raw); + if (!record) { + console.warn(' skipping malformed record in batch'); + continue; + } + + // TODO: replace with your own processing, and dedupe on + // `${envelope.syncId}:${record.hash}` so a redelivered batch is a no-op. + // The field KEYS below come from the sync configuration, so read them + // defensively — any key may be absent and any value may be null. + const fieldNames = Object.keys(record.fields); + console.log( + ` record ${record.hash ?? '(no hash)'}: ${fieldNames.length} field(s) ` + + `[${fieldNames.join(', ')}]` + ); + } +} + +app.post('/webhooks/polytomic', express.json(), (req, res) => { + // 1. Fail closed when no secret is configured. On a provider with NO + // signature, the bearer token is the entire security boundary — treating + // "unconfigured" as "accept everything" leaves a fully open endpoint that + // looks secure. + const authResult = verifyBearerToken(req.get('authorization'), webhookSecret()); + if (authResult === null) { + console.error('Polytomic webhook refused: POLYTOMIC_WEBHOOK_SECRET is not set'); + return res.status(500).json({ error: 'Webhook secret not configured' }); + } + if (authResult === false) { + console.error('Polytomic webhook rejected: bearer token mismatch'); + return res.status(401).json({ error: 'Invalid bearer token' }); + } + + // 2. Optional freshness check. Defence-in-depth only — see timestampIsFresh. + if (!timestampIsFresh(req.get('polytomic-signature-timestamp'))) { + console.error('Polytomic webhook rejected: stale or unparseable timestamp'); + return res.status(400).json({ error: 'Stale or invalid timestamp' }); + } + + // 3. Validate the envelope. express.json() has already turned invalid JSON + // into a 400 via the error handler below. + const envelope = parseEnvelope(req.body); + if (!envelope.ok) { + console.error(`Polytomic webhook rejected: ${envelope.error}`); + return res.status(400).json({ error: envelope.error }); + } + + // 4. Acknowledge FAST, then process. "On receipt of the payload, your API + // should return 200 OK. Any 4xx or 5xx error will cause the sync to appear + // as a failure." No retry policy or schedule is documented, so a failed + // run is an operational problem — don't risk it on a slow downstream. + res.status(200).json({ received: true }); + + // 5. Dispatch on the event type. There is EXACTLY ONE documented event: + // sync.records. "You should only process webhooks you know about—for right + // now, that is just the sync.records event." + switch (envelope.event) { + case 'sync.records': + handleSyncRecords(envelope); + break; + + default: + // IGNORE unknown events — do NOT error. The docs explicitly anticipate + // future event types, and a 4xx would mark the customer's sync run as + // failed. We already sent 200 above. + console.log(`Polytomic webhook: ignoring unknown event "${envelope.event}"`); + break; + } +}); + +// express.json() throws on a malformed body — turn that into a 400 rather than a +// 500. Must be registered after the route. +app.use((err, req, res, next) => { + if (err && err.type === 'entity.parse.failed') { + console.error('Polytomic webhook rejected: invalid JSON body'); + return res.status(400).json({ error: 'Invalid JSON' }); + } + return next(err); +}); + +// Health check endpoint +app.get('/health', (req, res) => { + res.json({ status: 'ok' }); +}); + +// Export app and helpers for testing +module.exports = { + app, + verifyBearerToken, + timestampIsFresh, + parseEnvelope, + normalizeRecord, +}; + +// Start server only when run directly (not when imported for testing) +if (require.main === module) { + const PORT = process.env.PORT || 3000; + app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); + console.log(`Webhook endpoint: POST http://localhost:${PORT}/webhooks/polytomic`); + }); +} diff --git a/skills/polytomic-webhooks/examples/express/test/webhook.test.js b/skills/polytomic-webhooks/examples/express/test/webhook.test.js new file mode 100644 index 00000000..9d9ecd42 --- /dev/null +++ b/skills/polytomic-webhooks/examples/express/test/webhook.test.js @@ -0,0 +1,367 @@ +// Generated with: polytomic-webhooks skill +// https://github.com/hookdeck/webhook-skills +const request = require('supertest'); + +// Polytomic does NOT sign its webhooks, so there are NO SIGNATURES TO GENERATE +// in these tests — no HMAC, no digest, nothing to compute. The only credential +// is the static shared bearer token (the connection Secret), which Polytomic +// echoes back verbatim in the Authorization header. +// +// This value mirrors the shape of the token in Polytomic's documented example, +// which happens to decode as an HS256 JWT with claims +// {"aud":"webhook","jti":"","iss":"https://app.polytomic-local.com:8443/"}. +// That is an observation about the documented example, not a documented format — +// the handler treats the whole string as an OPAQUE SECRET and never decodes it. +const SECRET = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + + 'eyJhdWQiOiJ3ZWJob29rIiwianRpIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAwIiwiaXNzIjoiaHR0cHM6Ly9hcHAucG9seXRvbWljLWxvY2FsLmNvbTo4NDQzLyJ9.' + + 'FBSU_fC1YFyWhMSPErRono4BPfkIeT3MkRdZrepiP3c'; +const WRONG_SECRET = SECRET.slice(0, -1) + 'X'; // same length, last char differs + +process.env.POLYTOMIC_WEBHOOK_SECRET = SECRET; + +const { + app, + verifyBearerToken, + timestampIsFresh, + parseEnvelope, + normalizeRecord, +} = require('../src/index'); + +const PATH = '/webhooks/polytomic'; + +/** RFC 3339 / ISO 8601 UTC, e.g. "2021-06-01T22:55:36Z" — the documented format. */ +function rfc3339Now(offsetSeconds = 0) { + return new Date(Date.now() + offsetSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** + * The documented payload, verbatim from + * https://docs.polytomic.com/docs/webhooks-connections + * + * Note that `fields` holds `email` / `last_login` because that is what THAT + * customer's sync selected — the keys are user-defined, not a Polytomic schema. + */ +function documentedPayload() { + return { + event: 'sync.records', + object: { + id: '1ea8f90a-b22e-4218-86d5-c3c109e1fbb7', + name: 'Webhook HTTP Endpoint sync', + records: [ + { + hash: 'b7421c6c57bd49f7', + fields: { + email: 'nathan@polytomic.com', + last_login: '2020-12-02T00:00:00Z', + }, + }, + ], + metadata: {}, + }, + }; +} + +/** Send a request with valid auth and a fresh timestamp unless overridden. */ +function post(payload, { secret = SECRET, timestamp = rfc3339Now(), raw = null } = {}) { + let req = request(app).post(PATH).set('Content-Type', 'application/json'); + if (secret !== null) req = req.set('Authorization', `Bearer ${secret}`); + if (timestamp !== null) req = req.set('Polytomic-Signature-Timestamp', timestamp); + return req.send(raw !== null ? raw : JSON.stringify(payload)); +} + +describe('bearer token authentication (there is no signature)', () => { + it('accepts the documented payload with a matching bearer token', async () => { + const res = await post(documentedPayload()); + expect(res.status).toBe(200); + expect(res.body).toEqual({ received: true }); + }); + + it('rejects a mismatched token with 401', async () => { + const res = await post(documentedPayload(), { secret: WRONG_SECRET }); + expect(res.status).toBe(401); + }); + + it('rejects a missing Authorization header with 401', async () => { + const res = await post(documentedPayload(), { secret: null }); + expect(res.status).toBe(401); + }); + + it('fails CLOSED with 500 when POLYTOMIC_WEBHOOK_SECRET is unset', async () => { + // On a provider with no signature, the bearer token is the entire security + // boundary. "Unconfigured" must never mean "accept everything". + delete process.env.POLYTOMIC_WEBHOOK_SECRET; + try { + const res = await post(documentedPayload()); + expect(res.status).toBe(500); + } finally { + process.env.POLYTOMIC_WEBHOOK_SECRET = SECRET; + } + }); + + it('strips exactly one "Bearer " prefix, case-insensitively on the scheme', () => { + // RFC 7235: the scheme is case-insensitive. + expect(verifyBearerToken(`Bearer ${SECRET}`, SECRET)).toBe(true); + expect(verifyBearerToken(`bearer ${SECRET}`, SECRET)).toBe(true); + expect(verifyBearerToken(`BEARER ${SECRET}`, SECRET)).toBe(true); + // Only ONE prefix is stripped, so a doubled scheme must not authenticate. + expect(verifyBearerToken(`Bearer Bearer ${SECRET}`, SECRET)).toBe(false); + }); + + it('accepts a bare token with no scheme (the prefix is optional to strip)', () => { + expect(verifyBearerToken(SECRET, SECRET)).toBe(true); + }); + + it('compares the token case-SENSITIVELY', () => { + expect(verifyBearerToken(`Bearer ${SECRET.toUpperCase()}`, SECRET)).toBe(false); + }); + + it('returns false rather than throwing on a length mismatch', () => { + // crypto.timingSafeEqual throws on unequal buffer lengths, and the attacker + // controls the incoming length — the length guard must come first. + expect(verifyBearerToken('Bearer short', SECRET)).toBe(false); + expect(verifyBearerToken(`Bearer ${SECRET}extra`, SECRET)).toBe(false); + }); + + it('returns null (not false) when the secret is unset, so callers can fail closed', () => { + expect(verifyBearerToken(`Bearer ${SECRET}`, '')).toBe(null); + expect(verifyBearerToken(`Bearer ${SECRET}`, undefined)).toBe(null); + }); + + it('returns false for a non-string Authorization value', () => { + expect(verifyBearerToken(undefined, SECRET)).toBe(false); + expect(verifyBearerToken(null, SECRET)).toBe(false); + }); + + it('does not treat the token as a JWT', () => { + // A JWT-aware verifier would reject this (bad signature / wrong issuer) or + // crash. An opaque byte-for-byte comparison accepts it, which is correct: + // real workspace secrets need not be JWTs at all. + const opaque = 'not-a-jwt-at-all-just-an-opaque-shared-secret'; + expect(verifyBearerToken(`Bearer ${opaque}`, opaque)).toBe(true); + }); +}); + +describe('Polytomic-Signature-Timestamp (a timestamp, NOT a signature)', () => { + it('accepts a fresh RFC 3339 timestamp', () => { + expect(timestampIsFresh(rfc3339Now(), 300)).toBe(true); + }); + + it('accepts the documented format exactly', () => { + // The docs' example: "2021-06-01T22:55:36Z". Fresh-at-the-time, so compare + // against a generous tolerance anchored to now instead. + expect(timestampIsFresh('2021-06-01T22:55:36Z', 0)).toBe(true); // check disabled + expect(timestampIsFresh('2021-06-01T22:55:36Z', 300)).toBe(false); // genuinely stale + }); + + it('rejects a timestamp older than the tolerance', () => { + expect(timestampIsFresh(rfc3339Now(-600), 300)).toBe(false); + }); + + it('rejects a timestamp too far in the future', () => { + expect(timestampIsFresh(rfc3339Now(600), 300)).toBe(false); + }); + + it('is NOT parsed as a Unix epoch integer', () => { + // parseInt("2021-06-01T22:55:36Z") === 2021 — a silent, catastrophic bug. + // An epoch-seconds value must therefore NOT be accepted as fresh. + const epochSeconds = String(Math.floor(Date.now() / 1000)); + expect(timestampIsFresh(epochSeconds, 300)).toBe(false); + }); + + it('rejects an unparseable value', () => { + expect(timestampIsFresh('not-a-timestamp', 300)).toBe(false); + expect(timestampIsFresh('', 300)).toBe(false); + expect(timestampIsFresh(undefined, 300)).toBe(false); + }); + + it('skips the check entirely when the tolerance is 0 or negative', () => { + expect(timestampIsFresh(undefined, 0)).toBe(true); + expect(timestampIsFresh('garbage', 0)).toBe(true); + expect(timestampIsFresh('garbage', -1)).toBe(true); + }); + + it('rejects a stale delivery with 400 over HTTP', async () => { + const res = await post(documentedPayload(), { timestamp: rfc3339Now(-3600) }); + expect(res.status).toBe(400); + }); + + it('authenticates before checking freshness (a bad token is 401, not 400)', async () => { + const res = await post(documentedPayload(), { + secret: WRONG_SECRET, + timestamp: rfc3339Now(-3600), + }); + expect(res.status).toBe(401); + }); + + it('honours POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=0 over HTTP', async () => { + process.env.POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS = '0'; + try { + const res = await post(documentedPayload(), { timestamp: '2021-06-01T22:55:36Z' }); + expect(res.status).toBe(200); + } finally { + delete process.env.POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS; + } + }); +}); + +describe('event dispatch (exactly one documented event: sync.records)', () => { + it('processes sync.records', async () => { + const res = await post(documentedPayload()); + expect(res.status).toBe(200); + }); + + it('IGNORES an unknown event with 200, rather than erroring', async () => { + // The docs anticipate future event types. A 4xx/5xx "will cause the sync to + // appear as a failure", so unknown events must be acknowledged. + const payload = documentedPayload(); + payload.event = 'some.future.event'; + delete payload.object.records; + const res = await post(payload); + expect(res.status).toBe(200); + expect(res.body).toEqual({ received: true }); + }); + + it('rejects a missing event field with 400', async () => { + const payload = documentedPayload(); + delete payload.event; + const res = await post(payload); + expect(res.status).toBe(400); + }); + + it('rejects a missing object envelope with 400', async () => { + const res = await post({ event: 'sync.records' }); + expect(res.status).toBe(400); + }); + + it('rejects invalid JSON with 400', async () => { + const res = await post(null, { raw: '{not json' }); + expect(res.status).toBe(400); + }); +}); + +describe('the batch (object.records is a LIST, default size 100)', () => { + it('handles a multi-record batch', async () => { + const payload = documentedPayload(); + payload.object.records = Array.from({ length: 250 }, (_, i) => ({ + hash: `hash${i}`, + fields: { email: `user${i}@example.com`, last_login: '2020-12-02T00:00:00Z' }, + })); + const res = await post(payload); + expect(res.status).toBe(200); + + const parsed = parseEnvelope(payload); + expect(parsed.ok).toBe(true); + // Batch size is user-configurable, so a handler must never assume 1. + expect(parsed.records).toHaveLength(250); + }); + + it('handles an empty batch', async () => { + const payload = documentedPayload(); + payload.object.records = []; + const res = await post(payload); + expect(res.status).toBe(200); + }); + + it('rejects a non-array records value with 400', async () => { + const payload = documentedPayload(); + payload.object.records = 'not-an-array'; + const res = await post(payload); + expect(res.status).toBe(400); + }); + + it('exposes hash and the sync id for idempotency', () => { + const parsed = parseEnvelope(documentedPayload()); + expect(parsed.syncId).toBe('1ea8f90a-b22e-4218-86d5-c3c109e1fbb7'); + expect(parsed.syncName).toBe('Webhook HTTP Endpoint sync'); + // Dedupe key: `${syncId}:${hash}`. hash is a CONTENT digest for dedupe only + // — never a credential. + expect(normalizeRecord(parsed.records[0]).hash).toBe('b7421c6c57bd49f7'); + }); +}); + +describe('records[].fields has user-defined keys', () => { + it('passes through whatever keys the sync selected', () => { + // A completely different sync configuration — no `email`, no `last_login`. + const record = normalizeRecord({ + hash: 'abc123', + fields: { account_id: 42, mrr: 199.5, is_churned: false, plan: null }, + }); + expect(Object.keys(record.fields).sort()).toEqual([ + 'account_id', + 'is_churned', + 'mrr', + 'plan', + ]); + // Null values survive — the handler must tolerate them, not assume strings. + expect(record.fields.plan).toBe(null); + }); + + it('defaults fields to an empty object when absent or malformed', () => { + expect(normalizeRecord({ hash: 'h' }).fields).toEqual({}); + expect(normalizeRecord({ hash: 'h', fields: null }).fields).toEqual({}); + expect(normalizeRecord({ hash: 'h', fields: 'nope' }).fields).toEqual({}); + expect(normalizeRecord({ hash: 'h', fields: [] }).fields).toEqual({}); + }); + + it('tolerates a record with no hash', () => { + expect(normalizeRecord({ fields: { a: 1 } }).hash).toBe(null); + }); + + it('returns null for a non-object record', () => { + expect(normalizeRecord(null)).toBe(null); + expect(normalizeRecord('nope')).toBe(null); + expect(normalizeRecord([])).toBe(null); + }); + + it('does not drop a batch because one record is malformed', async () => { + const payload = documentedPayload(); + payload.object.records = [documentedPayload().object.records[0], null, 'nope']; + const res = await post(payload); + expect(res.status).toBe(200); + }); +}); + +describe('object.metadata may be an object, null, or absent', () => { + it('accepts an object (the documented example sends {})', () => { + expect(parseEnvelope(documentedPayload()).metadata).toEqual({}); + + const payload = documentedPayload(); + payload.object.metadata = { env: 'production', tenant: 'acme' }; + expect(parseEnvelope(payload).metadata).toEqual({ env: 'production', tenant: 'acme' }); + }); + + it('accepts null (the Advanced settings default)', () => { + const payload = documentedPayload(); + payload.object.metadata = null; + expect(parseEnvelope(payload).metadata).toBe(null); + }); + + it('accepts it being absent entirely', async () => { + const payload = documentedPayload(); + delete payload.object.metadata; + expect(parseEnvelope(payload).metadata).toBe(null); + const res = await post(payload); + expect(res.status).toBe(200); + }); +}); + +describe('no HMAC anywhere in the verify path', () => { + it('the handler source contains no hashing calls', () => { + // Polytomic sends no signature, so there is nothing to digest. A verifier + // that computes an HMAC here is comparing against nothing. + const source = require('fs').readFileSync(require.resolve('../src/index.js'), 'utf8'); + // Match CALLS (with the paren), so the prose comments that name these + // functions in order to forbid them don't trip the assertion. + expect(source).not.toMatch(/createHmac\s*\(/); + expect(source).not.toMatch(/createHash\s*\(/); + }); +}); + +describe('health check', () => { + it('responds ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok' }); + }); +}); diff --git a/skills/polytomic-webhooks/examples/fastapi/.env.example b/skills/polytomic-webhooks/examples/fastapi/.env.example new file mode 100644 index 00000000..d9e05009 --- /dev/null +++ b/skills/polytomic-webhooks/examples/fastapi/.env.example @@ -0,0 +1,25 @@ +# REQUIRED. The connection Secret from Polytomic. +# +# Connections -> your Webhook connection -> hover the secret key field to reveal +# its value. Polytomic sends this exact value back on every request as +# Authorization: Bearer +# +# This is NOT a signing secret. Polytomic does not sign its webhooks — there is +# no HMAC, no digest and no signature header. The bearer token is the ONLY +# authentication. If this is unset the handler fails closed with 500. +# +# The value may look like a JWT (the docs' example does). Treat it as an opaque +# string: do not decode it, do not jwt.verify() it. +POLYTOMIC_WEBHOOK_SECRET= + +# OPTIONAL. Freshness window for the Polytomic-Signature-Timestamp header, in +# seconds. Default 300 (the docs suggest rejecting requests "more than a few +# minutes old"). Set to 0 to disable the check entirely. +# +# DEFENCE-IN-DEPTH ONLY. That header is an RFC 3339 timestamp, NOT a signature, +# and it is not covered by any signature — so this proves nothing about +# authenticity. It only limits replay of an old captured request. +POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=300 + +# Optional. Port for the example server. +PORT=8000 diff --git a/skills/polytomic-webhooks/examples/fastapi/README.md b/skills/polytomic-webhooks/examples/fastapi/README.md new file mode 100644 index 00000000..3e634708 --- /dev/null +++ b/skills/polytomic-webhooks/examples/fastapi/README.md @@ -0,0 +1,182 @@ +# Polytomic Webhooks - FastAPI Example + +Minimal example of receiving **Polytomic sync record batches** in FastAPI. + +> **There is no signature to verify.** Polytomic does not sign its webhook +> payloads — no HMAC, no digest, no signing secret, no signature header, no +> `X-Polytomic-*` header of any kind. The **only** authentication is a **static +> shared bearer token** matching the connection Secret. See the skill's +> [references/verification.md](../../references/verification.md). + +> **`Polytomic-Signature-Timestamp` is not a signature.** Despite the name, it +> carries only an RFC 3339 UTC timestamp (`2021-06-01T22:55:36Z`). It is used here +> for an optional freshness check, which is **defence-in-depth only** — the +> timestamp is not covered by any signature, so it proves nothing about +> authenticity. + +## What this example shows + +- `POST /webhooks/polytomic` — authenticating by comparing the `Authorization` + bearer token against `POLYTOMIC_WEBHOOK_SECRET` in **constant time** via + `hmac.compare_digest`, stripping exactly one `Bearer ` prefix + (case-insensitively on the scheme, per RFC 7235). +- **Fail closed:** if `POLYTOMIC_WEBHOOK_SECRET` is unset the route returns `500` + and processes nothing. On a provider with no signature, the bearer token is the + *entire* security boundary — "unconfigured" must never mean "accept everything". +- **No HMAC is computed.** `hmac.new()` is never called and `hashlib` is never + imported — a test walks the module's AST to prove it. `hmac.compare_digest` is + used purely as Python's constant-time **byte comparison**, which is a different + thing from computing a digest. +- **There is no SDK fallback to write.** Polytomic publishes no webhook verifier + helper in any language, because there is no signature scheme to wrap. Manual + comparison is *the* implementation, not a workaround for a Node-only SDK. +- **Bytes, not `str`.** `hmac.compare_digest()` raises `TypeError` on `str` values + containing non-ASCII characters, and the header is attacker-supplied — so the + token is encoded to UTF-8 bytes first. A test sends a Unicode token. +- **The bearer token is treated as an opaque secret.** The documented sample value + happens to decode as an HS256 JWT, but it is signed with a key you don't hold, + has no `exp`, and the docs call it "a static value" — so no `jwt.decode`, no + `aud`/`iss`/`exp` checks. A test proves a non-JWT secret works. +- **Optional timestamp freshness** (`POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS`, + default 300, `0` disables) parsed with `datetime.fromisoformat()` — **never + `int()`**. The trailing `Z` is normalised to `+00:00`, because + `fromisoformat()` only accepts a literal `Z` natively on **Python 3.11+**. +- **Batch handling.** `object.records` is a list (default size 100, + user-configurable), so the handler **always loops**. Tested with a 250-record + batch and an empty one. +- **`records[].fields` is a plain `Dict[str, Any]`** — deliberately *not* a + Pydantic model. The keys are defined by the sync configuration, so a model over + `email` / `last_login` would be wrong for every other customer. +- **`object.metadata` may be a dict, `None`, or absent** — all three tested. +- **Event dispatch on the single documented event, `sync.records`**, with the + else-branch **ignoring unknown events with a 200** (the docs anticipate future + types, and a 4xx would mark the customer's sync run as failed). + +Because there is no signature, there is **no raw-body requirement** — the handler +uses `await request.json()` rather than capturing `await request.body()`. +(Polytomic may gzip the body; your ASGI server and reverse proxy decompress that +transparently.) Malformed JSON becomes a `400`. + +## Prerequisites + +- Python 3.9+ +- A Polytomic account with a **Webhook connection** and a **Model Sync** pointed + at it. A connection alone delivers nothing — the sync is what sends. + +## Setup + +1. Create a virtual environment and install dependencies: + ```bash + python3 -m venv venv + source venv/bin/activate + pip install -r requirements.txt + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env + ``` + +3. Add your Polytomic connection Secret to `.env` as + `POLYTOMIC_WEBHOOK_SECRET`. In Polytomic: **Connections** → your Webhook + connection → **hover the secret key field to reveal its value**. + + There is **no signing secret** to configure — Polytomic has none. This value + is the shared bearer token Polytomic sends back on every request. + +## Run + +```bash +uvicorn main:app --reload --port 8000 +``` + +Server runs on http://localhost:8000, with the webhook at +`POST /webhooks/polytomic`. + +## Test + +```bash +pytest test_webhook.py -v +``` + +Replay the documented payload by hand: + +```bash +curl -X POST http://localhost:8000/webhooks/polytomic \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $POLYTOMIC_WEBHOOK_SECRET" \ + -H "Polytomic-Signature-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -d '{ + "event": "sync.records", + "object": { + "id": "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name": "Webhook HTTP Endpoint sync", + "records": [ + { + "hash": "b7421c6c57bd49f7", + "fields": { "email": "nathan@polytomic.com", "last_login": "2020-12-02T00:00:00Z" } + } + ], + "metadata": {} + } + }' +``` + +Note the timestamp format: **RFC 3339 UTC**, not a Unix epoch integer. + +Polytomic documents **no test event, no handshake and no "send test" button** — to +exercise the real path you must run the sync. Watch the delivery in Polytomic's +**sync history** view (Advanced settings → *Capture webhook requests and +responses*, default on). + +### Receive webhooks locally + +```bash +npx hookdeck-cli listen 8000 polytomic --path /webhooks/polytomic +``` + +No account required — the CLI creates a guest account on first run and gives you +a public HTTPS URL plus a web UI for inspecting requests. Paste that URL into the +Webhook connection, then run your sync. + +Because Polytomic sends no signature, there is nothing for a gateway to verify on +the **Polytomic → gateway** hop. What a gateway adds here is retries and replay +that Polytomic itself does not document — which matters, because a single 5xx +marks the entire sync run failed. + +## Processing the batch asynchronously + +This example processes inline to keep it readable. In production, acknowledge +first and process after — *"Any 4xx or 5xx error will cause the sync to appear as a +failure"*, so a slow downstream fails your customer's sync run. Either hand the +batch to a real queue, or use FastAPI's `BackgroundTasks`: + +```python +from fastapi import BackgroundTasks + +@app.post("/webhooks/polytomic") +async def polytomic_webhook(request: Request, background_tasks: BackgroundTasks): + ... # authenticate, check freshness, parse + background_tasks.add_task(handle_sync_records, envelope) + return JSONResponse({"received": True}, status_code=200) +``` + +Note that `BackgroundTasks` runs in the same process — it is not durable across a +restart or a crash. For at-least-once handling of a large batch, enqueue to +something persistent and dedupe on `f"{sync_id}:{hash}"`. + +## Endpoint + +- `POST /webhooks/polytomic` — `500` if `POLYTOMIC_WEBHOOK_SECRET` is unset (fail + closed), `401` on a missing or mismatched bearer token, `400` on a stale or + unparseable `Polytomic-Signature-Timestamp` (when the tolerance is > 0), `400` + on invalid JSON or a malformed envelope, `200` otherwise — including for + unknown event types, which are ignored. +- `GET /health` — health check. + +## A note on the first delivery + +If your first batch is enormous, that is not a bug: *"The first time a Polytomic +Model Sync runs, it will sync everything in the source."* Either enable **Skip +backfill on first sync** in the sync's Advanced settings, or make sure you enqueue +rather than process inline. diff --git a/skills/polytomic-webhooks/examples/fastapi/main.py b/skills/polytomic-webhooks/examples/fastapi/main.py new file mode 100644 index 00000000..fd7830b2 --- /dev/null +++ b/skills/polytomic-webhooks/examples/fastapi/main.py @@ -0,0 +1,346 @@ +# Generated with: polytomic-webhooks skill +# https://github.com/hookdeck/webhook-skills +import hmac +import os +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple, Union + +from dotenv import load_dotenv +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +load_dotenv() + +# Polytomic Webhook destination receiver. +# +# Polytomic is a data-movement platform. Its one outbound-HTTP surface is the +# Webhook *connection used as a sync destination*: you add a Webhook connection, +# point a Model Sync at it, and Polytomic POSTs BATCHES OF CHANGED RECORDS to +# your URL on the sync's schedule. This is not event subscription — there is no +# event-subscription UI and no per-event-type toggles. +# +# Three things make Polytomic unlike most providers: +# +# 1. THERE IS NO SIGNATURE. No HMAC, no digest, no signing secret, no +# signature header, and no X-Polytomic-* header of any kind. The ONLY +# authentication is a STATIC SHARED BEARER TOKEN matching the connection +# Secret. Verbatim from the docs: "This should be a 'Bearer' token matching +# the same value that was provided as the 'Secret' during connection setup. +# For now, this is the only request authorization and is a static value." +# Note: hashlib is not imported and hmac.new() is never called in this file. +# `hmac.compare_digest` is used purely as Python's constant-time BYTE +# COMPARISON — no HMAC is computed, because there is no signature to +# compare a digest against. +# +# There is also no Polytomic SDK webhook verifier to fall back from: no SDK +# helper exists, because there is no signature scheme to wrap. Manual +# comparison is the implementation in every language. +# +# 2. `Polytomic-Signature-Timestamp` IS NOT A SIGNATURE, despite its name. It +# carries only an RFC 3339 / ISO 8601 UTC timestamp ("2021-06-01T22:55:36Z") +# — not a Unix epoch integer and not a digest. Parse it with +# datetime.fromisoformat(), NEVER int(). The freshness check below is +# defence-in-depth only: the timestamp is not covered by any signature, so +# an attacker holding the bearer token can set any value they like. +# +# 3. EVERY PAYLOAD IS A BATCH. `object.records` is "a list of the records +# changed since the last payload" — default batch size 100, and +# user-configurable. We always loop. Never assume one record. +# +# Because there is no signature, there is NO raw-body requirement here — we can +# use `await request.json()` rather than capturing `await request.body()`. +# (Polytomic may gzip the body; your ASGI server / reverse proxy decompresses +# that transparently.) + +app = FastAPI(title="Polytomic Webhook Handler") + +# Default freshness window, in seconds — the docs' "more than a few minutes old". +DEFAULT_TOLERANCE_SECONDS = 300 + +if not os.getenv("POLYTOMIC_WEBHOOK_SECRET"): + print( + "WARNING: POLYTOMIC_WEBHOOK_SECRET is not set — the webhook route will " + "fail closed with 500. Polytomic does not sign its payloads, so this " + "shared bearer token is the ONLY authentication available. Reveal it by " + "hovering the secret key field on the Webhook connection in Polytomic." + ) + + +def webhook_secret() -> str: + """Read per request, so the fail-closed path stays testable and a rotation + doesn't need a restart.""" + return os.getenv("POLYTOMIC_WEBHOOK_SECRET") or "" + + +def tolerance_seconds() -> int: + raw = os.getenv("POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS") + if raw is None or raw == "": + return DEFAULT_TOLERANCE_SECONDS + try: + return int(raw) + except ValueError: + return DEFAULT_TOLERANCE_SECONDS + + +def verify_bearer_token( + authorization_header: Optional[str], secret: Optional[str] +) -> Optional[bool]: + """ + Authenticate the request by comparing the Authorization bearer token against + the connection Secret, in constant time. + + This is the WHOLE of Polytomic's authentication. There is no signature to + verify: no HMAC is computed here, because no signature header is sent. + + The documented sample token happens to decode as an HS256 JWT (claims + {"aud":"webhook","jti":"","iss":"https://app.polytomic-local.com:8443/"} + — `iss` is the issuing Polytomic instance, and the docs' example is a local + dev host). Do NOT treat it as a JWT: it is signed with a key Polytomic does + not give you, it carries no `exp`, and the docs call it "a static value". + Calling jwt.decode()/jwt.verify() or checking aud/iss/exp would either raise + or add a false sense of security. Compare the whole string byte-for-byte. + + Returns None when unconfigured — the caller MUST fail closed with 500. + """ + if not secret: + return None # unset => fail closed, never silently accept + if not authorization_header: + return False + + # Strip exactly ONE leading "Bearer " prefix. The scheme is case-insensitive + # per RFC 7235; the token after it is not. + token = authorization_header + if token[:7].lower() == "bearer ": + token = token[7:] + + # Compare BYTES, not str: hmac.compare_digest() raises TypeError on str + # values containing non-ASCII characters, and the header is attacker-supplied. + # Note this computes NO HMAC — it is just a constant-time byte comparison. + return hmac.compare_digest(token.encode("utf-8"), secret.encode("utf-8")) + + +def timestamp_is_fresh( + timestamp_header: Optional[str], tolerance: Optional[int] = None +) -> bool: + """ + Optional freshness check on Polytomic-Signature-Timestamp. + + The docs endorse this: "In general, it is a good idea to reject requests + older than you expect (more than a few minutes old)." But the timestamp is + NOT covered by any signature, so this proves nothing about authenticity — it + only limits replay of an OLD CAPTURED REQUEST. + + The value is RFC 3339 UTC, e.g. "2021-06-01T22:55:36Z". NEVER int() it. + + `tolerance` in seconds; <= 0 disables the check. + """ + if tolerance is None: + tolerance = tolerance_seconds() + if tolerance <= 0: + return True # check disabled + if not timestamp_header or not timestamp_header.strip(): + return False + + value = timestamp_header.strip() + try: + # datetime.fromisoformat() only accepts a literal trailing "Z" on Python + # 3.11+, so normalise it to "+00:00" for 3.9/3.10 compatibility. + if value.endswith(("Z", "z")): + value = value[:-1] + "+00:00" + sent = datetime.fromisoformat(value) + except ValueError: + return False + + # The docs' format is explicitly UTC; assume UTC if an offset is missing. + if sent.tzinfo is None: + sent = sent.replace(tzinfo=timezone.utc) + + delta = abs((datetime.now(timezone.utc) - sent).total_seconds()) + return delta <= tolerance + + +def parse_envelope( + payload: Any, +) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + """ + Defensive validation of the documented envelope. + + Documented shape: + {"event": "sync.records", + "object": {"id": "", "name": "", + "records": [{"hash": "...", "fields": {...}}], + "metadata": {}}} + + `object` is "an envelope that will contain the payload, regardless of event" + and is always present. `metadata` defaults to null in the sync + configuration, so it may be a dict, None, OR ABSENT — all three are handled. + + Returns (envelope, None) on success or (None, error) on failure. + """ + if not isinstance(payload, dict): + return None, "payload is not a JSON object" + + event = payload.get("event") + if not isinstance(event, str) or event == "": + return None, "missing or invalid event" + + envelope = payload.get("object") + if not isinstance(envelope, dict): + return None, "missing or invalid object envelope" + + # `records` is absent on event types we don't know about yet, so it is only + # required for sync.records. + records = envelope.get("records") + if records is not None and not isinstance(records, list): + return None, "object.records is not an array" + + metadata = envelope.get("metadata") + + return ( + { + "event": event, + # The UUID of the SYNC (it matches the id in the Polytomic UI's URL + # bar for that sync config) — not of the delivery or the record. + "sync_id": envelope.get("id") if isinstance(envelope.get("id"), str) else None, + # The sync's name: "useful for discriminating against data coming in + # from different endpoints." + "sync_name": envelope.get("name") if isinstance(envelope.get("name"), str) else None, + "records": records if isinstance(records, list) else [], + # Absent / None / dict all collapse to None-or-dict here. + "metadata": metadata if isinstance(metadata, dict) else None, + }, + None, + ) + + +def normalize_record(record: Any) -> Optional[Dict[str, Any]]: + """ + Normalize one record from the batch. + + `fields` "contains each of the fields you selected to be delivered" — THE + KEYS ARE USER-DEFINED by the sync configuration. The `email` / `last_login` + in the docs' example are that customer's chosen fields, NOT a Polytomic + schema. So this is deliberately NOT modelled as a fixed shape (no Pydantic + model over `fields`); access it defensively. + + `hash` is "a computed hash of the record's fields key/values pairs, which may + be useful for deduplicating incoming data" — i.e. an idempotency key. Its + algorithm and length are undocumented, so never recompute or assume them, + and NEVER use it for authentication: it is a digest over data Polytomic is + sending you, computed by Polytomic. + + Returns None if the record is unusable. + """ + if not isinstance(record, dict): + return None + + raw_fields = record.get("fields") + fields: Dict[str, Any] = raw_fields if isinstance(raw_fields, dict) else {} + + record_hash = record.get("hash") + return { + "hash": record_hash if isinstance(record_hash, str) and record_hash != "" else None, + "fields": fields, + } + + +def handle_sync_records(envelope: Dict[str, Any]) -> None: + """ + Handle the sync.records batch. + + ALWAYS LOOPS. Default batch size is 100 ("Webhook batch size (default: 100)" + under the sync's Advanced settings) and it is user-configurable, so never + assume one record and write for large batches. + + In production, hand this to a background task or queue — the route returns + 200 first. A 4xx/5xx "will cause the sync to appear as a failure", so a slow + downstream would fail your customer's whole sync run. + """ + records: List[Any] = envelope["records"] + print( + f"Polytomic sync.records: sync \"{envelope['sync_name'] or 'unknown'}\" " + f"({envelope['sync_id'] or 'no id'}) delivered {len(records)} record(s)" + ) + + if envelope["metadata"]: + # Hardcoded key/values from Advanced settings -> Metadata (default: null). + print(f" metadata: {envelope['metadata']}") + + for raw in records: + record = normalize_record(raw) + if record is None: + print(" skipping malformed record in batch") + continue + + # TODO: replace with your own processing, and dedupe on + # f"{envelope['sync_id']}:{record['hash']}" so a redelivered batch is a + # no-op. The field KEYS below come from the sync configuration, so read + # them defensively — any key may be absent and any value may be None. + field_names = list(record["fields"].keys()) + print( + f" record {record['hash'] or '(no hash)'}: {len(field_names)} field(s) " + f"[{', '.join(field_names)}]" + ) + + +@app.post("/webhooks/polytomic") +async def polytomic_webhook(request: Request) -> JSONResponse: + # 1. Fail closed when no secret is configured. On a provider with NO + # signature, the bearer token is the entire security boundary — treating + # "unconfigured" as "accept everything" leaves a fully open endpoint that + # looks secure. + # + # Header names are matched case-insensitively by Starlette's Headers. + auth_result = verify_bearer_token(request.headers.get("authorization"), webhook_secret()) + if auth_result is None: + print("Polytomic webhook refused: POLYTOMIC_WEBHOOK_SECRET is not set") + return JSONResponse({"error": "Webhook secret not configured"}, status_code=500) + if auth_result is False: + print("Polytomic webhook rejected: bearer token mismatch") + return JSONResponse({"error": "Invalid bearer token"}, status_code=401) + + # 2. Optional freshness check. Defence-in-depth only — see timestamp_is_fresh. + if not timestamp_is_fresh(request.headers.get("polytomic-signature-timestamp")): + print("Polytomic webhook rejected: stale or unparseable timestamp") + return JSONResponse({"error": "Stale or invalid timestamp"}, status_code=400) + + # 3. Parse the body. No signature means no raw-body requirement. + try: + payload = await request.json() + except Exception: + print("Polytomic webhook rejected: invalid JSON body") + return JSONResponse({"error": "Invalid JSON"}, status_code=400) + + envelope, error = parse_envelope(payload) + if error is not None or envelope is None: + print(f"Polytomic webhook rejected: {error}") + return JSONResponse({"error": error}, status_code=400) + + # 4. Dispatch on the event type. There is EXACTLY ONE documented event: + # sync.records. "You should only process webhooks you know about—for right + # now, that is just the sync.records event." + # + # In a real deployment, enqueue here (or use a BackgroundTask) so the 200 + # below is returned immediately. "On receipt of the payload, your API + # should return 200 OK. Any 4xx or 5xx error will cause the sync to appear + # as a failure", and no retry policy is documented. + if envelope["event"] == "sync.records": + handle_sync_records(envelope) + else: + # IGNORE unknown events — do NOT error. The docs explicitly anticipate + # future event types, and a 4xx would mark the customer's sync run failed. + print(f"Polytomic webhook: ignoring unknown event \"{envelope['event']}\"") + + return JSONResponse({"received": True}, status_code=200) + + +@app.get("/health") +async def health() -> Dict[str, str]: + return {"status": "ok"} + + +if __name__ == "__main__": + import uvicorn + + port = int(os.getenv("PORT", "8000")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/skills/polytomic-webhooks/examples/fastapi/requirements.txt b/skills/polytomic-webhooks/examples/fastapi/requirements.txt new file mode 100644 index 00000000..507e2e4c --- /dev/null +++ b/skills/polytomic-webhooks/examples/fastapi/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.142.2 +uvicorn>=0.30.0 +python-dotenv>=1.0.0 +httpx>=0.28.1 +pytest>=9.1.1 diff --git a/skills/polytomic-webhooks/examples/fastapi/test_webhook.py b/skills/polytomic-webhooks/examples/fastapi/test_webhook.py new file mode 100644 index 00000000..1a0ad00e --- /dev/null +++ b/skills/polytomic-webhooks/examples/fastapi/test_webhook.py @@ -0,0 +1,444 @@ +# Generated with: polytomic-webhooks skill +# https://github.com/hookdeck/webhook-skills +import ast +import copy +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Optional + +import pytest +from fastapi.testclient import TestClient + +from main import ( + app, + normalize_record, + parse_envelope, + timestamp_is_fresh, + verify_bearer_token, +) + +# Polytomic does NOT sign its webhooks, so there are NO SIGNATURES TO GENERATE in +# these tests — no HMAC, no digest, nothing to compute. The only credential is the +# static shared bearer token (the connection Secret), which Polytomic echoes back +# verbatim in the Authorization header. +# +# This value mirrors the shape of the token in Polytomic's documented example, +# which happens to decode as an HS256 JWT with claims +# {"aud":"webhook","jti":"","iss":"https://app.polytomic-local.com:8443/"}. +# That is an observation about the documented example, not a documented format — +# the handler treats the whole string as an OPAQUE SECRET and never decodes it. +SECRET = ( + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJhdWQiOiJ3ZWJob29rIiwianRpIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAwIiwiaXNzIjoiaHR0cHM6Ly9hcHAucG9seXRvbWljLWxvY2FsLmNvbTo4NDQzLyJ9." + "FBSU_fC1YFyWhMSPErRono4BPfkIeT3MkRdZrepiP3c" +) +WRONG_SECRET = SECRET[:-1] + "X" # same length, last char differs + +PATH = "/webhooks/polytomic" + +client = TestClient(app) + +# The documented payload, verbatim from +# https://docs.polytomic.com/docs/webhooks-connections +# +# `fields` holds `email` / `last_login` because that is what THAT customer's sync +# selected — the keys are user-defined, not a Polytomic schema. +DOCUMENTED_PAYLOAD: Dict[str, Any] = { + "event": "sync.records", + "object": { + "id": "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name": "Webhook HTTP Endpoint sync", + "records": [ + { + "hash": "b7421c6c57bd49f7", + "fields": { + "email": "nathan@polytomic.com", + "last_login": "2020-12-02T00:00:00Z", + }, + } + ], + "metadata": {}, + }, +} + + +@pytest.fixture(autouse=True) +def configured_secret(monkeypatch): + """Every test starts with a correctly configured secret and no tolerance + override. Tests that need otherwise override it explicitly.""" + monkeypatch.setenv("POLYTOMIC_WEBHOOK_SECRET", SECRET) + monkeypatch.delenv("POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS", raising=False) + + +def rfc3339_now(offset_seconds: int = 0) -> str: + """RFC 3339 / ISO 8601 UTC, e.g. "2021-06-01T22:55:36Z" — the documented format.""" + moment = datetime.now(timezone.utc) + timedelta(seconds=offset_seconds) + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def payload() -> Dict[str, Any]: + return copy.deepcopy(DOCUMENTED_PAYLOAD) + + +def post( + body: Optional[Dict[str, Any]] = None, + secret: Optional[str] = SECRET, + timestamp: Optional[str] = None, + raw: Optional[str] = None, +): + headers = {"Content-Type": "application/json"} + if secret is not None: + headers["Authorization"] = f"Bearer {secret}" + headers["Polytomic-Signature-Timestamp"] = ( + timestamp if timestamp is not None else rfc3339_now() + ) + + if raw is not None: + return client.post(PATH, content=raw, headers=headers) + return client.post(PATH, json=body if body is not None else payload(), headers=headers) + + +# --------------------------------------------------------------------------- +# Bearer token authentication (there is no signature) +# --------------------------------------------------------------------------- + + +def test_accepts_documented_payload_with_matching_token(): + response = post() + assert response.status_code == 200 + assert response.json() == {"received": True} + + +def test_rejects_mismatched_token_with_401(): + response = post(secret=WRONG_SECRET) + assert response.status_code == 401 + + +def test_rejects_missing_authorization_header_with_401(): + response = post(secret=None) + assert response.status_code == 401 + + +def test_fails_closed_with_500_when_secret_unset(monkeypatch): + # On a provider with no signature, the bearer token is the entire security + # boundary. "Unconfigured" must never mean "accept everything". + monkeypatch.delenv("POLYTOMIC_WEBHOOK_SECRET", raising=False) + response = post() + assert response.status_code == 500 + + +def test_strips_exactly_one_bearer_prefix_case_insensitively(): + # RFC 7235: the scheme is case-insensitive. + assert verify_bearer_token(f"Bearer {SECRET}", SECRET) is True + assert verify_bearer_token(f"bearer {SECRET}", SECRET) is True + assert verify_bearer_token(f"BEARER {SECRET}", SECRET) is True + # Only ONE prefix is stripped, so a doubled scheme must not authenticate. + assert verify_bearer_token(f"Bearer Bearer {SECRET}", SECRET) is False + + +def test_accepts_bare_token_with_no_scheme(): + assert verify_bearer_token(SECRET, SECRET) is True + + +def test_compares_token_case_sensitively(): + assert verify_bearer_token(f"Bearer {SECRET.upper()}", SECRET) is False + + +def test_returns_false_on_length_mismatch_without_raising(): + # hmac.compare_digest handles unequal lengths without raising, but it must + # still report a mismatch rather than truncating. + assert verify_bearer_token("Bearer short", SECRET) is False + assert verify_bearer_token(f"Bearer {SECRET}extra", SECRET) is False + + +def test_returns_none_when_secret_unset_so_caller_can_fail_closed(): + assert verify_bearer_token(f"Bearer {SECRET}", "") is None + assert verify_bearer_token(f"Bearer {SECRET}", None) is None + + +def test_returns_false_for_missing_authorization_value(): + assert verify_bearer_token(None, SECRET) is False + assert verify_bearer_token("", SECRET) is False + + +def test_handles_non_ascii_authorization_without_raising(): + # hmac.compare_digest() raises TypeError on str values containing non-ASCII + # characters, so the implementation must encode to bytes first. The header is + # attacker-supplied, so this is reachable. + assert verify_bearer_token("Bearer tökén-wîth-ünicode", SECRET) is False + + +def test_does_not_treat_the_token_as_a_jwt(): + # A JWT-aware verifier would reject this or raise. An opaque byte-for-byte + # comparison accepts it, which is correct: real workspace secrets need not be + # JWTs at all. + opaque = "not-a-jwt-at-all-just-an-opaque-shared-secret" + assert verify_bearer_token(f"Bearer {opaque}", opaque) is True + + +# --------------------------------------------------------------------------- +# Polytomic-Signature-Timestamp (a timestamp, NOT a signature) +# --------------------------------------------------------------------------- + + +def test_accepts_fresh_rfc3339_timestamp(): + assert timestamp_is_fresh(rfc3339_now(), 300) is True + + +def test_parses_the_documented_format(): + # The docs' example: "2021-06-01T22:55:36Z" — parseable, but long stale. + assert timestamp_is_fresh("2021-06-01T22:55:36Z", 0) is True # check disabled + assert timestamp_is_fresh("2021-06-01T22:55:36Z", 300) is False # genuinely stale + + +def test_handles_trailing_z_on_python_39_and_310(): + # datetime.fromisoformat() only accepts a literal "Z" natively on 3.11+, so + # the implementation normalises it to "+00:00". + assert timestamp_is_fresh(rfc3339_now(), 300) is True + # An explicit offset must work too. + explicit = datetime.now(timezone.utc).isoformat() + assert timestamp_is_fresh(explicit, 300) is True + + +def test_rejects_timestamp_older_than_tolerance(): + assert timestamp_is_fresh(rfc3339_now(-600), 300) is False + + +def test_rejects_timestamp_too_far_in_the_future(): + assert timestamp_is_fresh(rfc3339_now(600), 300) is False + + +def test_is_not_parsed_as_a_unix_epoch_integer(): + # int("2021-06-01T22:55:36Z") raises, and a bare epoch value is NOT the + # documented format — it must not be accepted as fresh. + epoch_seconds = str(int(datetime.now(timezone.utc).timestamp())) + assert timestamp_is_fresh(epoch_seconds, 300) is False + + +def test_rejects_unparseable_timestamp(): + assert timestamp_is_fresh("not-a-timestamp", 300) is False + assert timestamp_is_fresh("", 300) is False + assert timestamp_is_fresh(None, 300) is False + + +def test_skips_check_when_tolerance_is_zero_or_negative(): + assert timestamp_is_fresh(None, 0) is True + assert timestamp_is_fresh("garbage", 0) is True + assert timestamp_is_fresh("garbage", -1) is True + + +def test_rejects_stale_delivery_with_400_over_http(): + response = post(timestamp=rfc3339_now(-3600)) + assert response.status_code == 400 + + +def test_authenticates_before_checking_freshness(): + # A bad token is 401, not 400 — authentication comes first. + response = post(secret=WRONG_SECRET, timestamp=rfc3339_now(-3600)) + assert response.status_code == 401 + + +def test_honours_tolerance_env_var_of_zero(monkeypatch): + monkeypatch.setenv("POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS", "0") + response = post(timestamp="2021-06-01T22:55:36Z") + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Event dispatch (exactly one documented event: sync.records) +# --------------------------------------------------------------------------- + + +def test_processes_sync_records(): + response = post() + assert response.status_code == 200 + + +def test_ignores_unknown_event_with_200(): + # The docs anticipate future event types. A 4xx/5xx "will cause the sync to + # appear as a failure", so unknown events must be acknowledged. + body = payload() + body["event"] = "some.future.event" + del body["object"]["records"] + response = post(body) + assert response.status_code == 200 + assert response.json() == {"received": True} + + +def test_rejects_missing_event_with_400(): + body = payload() + del body["event"] + response = post(body) + assert response.status_code == 400 + + +def test_rejects_missing_object_envelope_with_400(): + response = post({"event": "sync.records"}) + assert response.status_code == 400 + + +def test_rejects_invalid_json_with_400(): + response = post(raw="{not json") + assert response.status_code == 400 + + +# --------------------------------------------------------------------------- +# The batch (object.records is a LIST, default size 100) +# --------------------------------------------------------------------------- + + +def test_handles_a_multi_record_batch(): + body = payload() + body["object"]["records"] = [ + { + "hash": f"hash{i}", + "fields": {"email": f"user{i}@example.com", "last_login": "2020-12-02T00:00:00Z"}, + } + for i in range(250) + ] + response = post(body) + assert response.status_code == 200 + + envelope, error = parse_envelope(body) + assert error is None + # Batch size is user-configurable, so a handler must never assume 1. + assert len(envelope["records"]) == 250 + + +def test_handles_an_empty_batch(): + body = payload() + body["object"]["records"] = [] + response = post(body) + assert response.status_code == 200 + + +def test_rejects_non_array_records_with_400(): + body = payload() + body["object"]["records"] = "not-an-array" + response = post(body) + assert response.status_code == 400 + + +def test_exposes_hash_and_sync_id_for_idempotency(): + envelope, error = parse_envelope(payload()) + assert error is None + assert envelope["sync_id"] == "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7" + assert envelope["sync_name"] == "Webhook HTTP Endpoint sync" + # Dedupe key: f"{sync_id}:{hash}". hash is a CONTENT digest for dedupe only — + # never a credential. + assert normalize_record(envelope["records"][0])["hash"] == "b7421c6c57bd49f7" + + +# --------------------------------------------------------------------------- +# records[].fields has user-defined keys +# --------------------------------------------------------------------------- + + +def test_passes_through_whatever_keys_the_sync_selected(): + # A completely different sync configuration — no `email`, no `last_login`. + record = normalize_record( + { + "hash": "abc123", + "fields": {"account_id": 42, "mrr": 199.5, "is_churned": False, "plan": None}, + } + ) + assert sorted(record["fields"].keys()) == ["account_id", "is_churned", "mrr", "plan"] + # None values survive — the handler must tolerate them, not assume strings. + assert record["fields"]["plan"] is None + + +def test_defaults_fields_to_empty_dict_when_absent_or_malformed(): + assert normalize_record({"hash": "h"})["fields"] == {} + assert normalize_record({"hash": "h", "fields": None})["fields"] == {} + assert normalize_record({"hash": "h", "fields": "nope"})["fields"] == {} + assert normalize_record({"hash": "h", "fields": []})["fields"] == {} + + +def test_tolerates_a_record_with_no_hash(): + assert normalize_record({"fields": {"a": 1}})["hash"] is None + + +def test_returns_none_for_a_non_dict_record(): + assert normalize_record(None) is None + assert normalize_record("nope") is None + assert normalize_record([]) is None + + +def test_does_not_drop_a_batch_because_one_record_is_malformed(): + body = payload() + body["object"]["records"] = [DOCUMENTED_PAYLOAD["object"]["records"][0], None, "nope"] + response = post(body) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# object.metadata may be a dict, None, or absent +# --------------------------------------------------------------------------- + + +def test_metadata_accepts_a_dict(): + envelope, _ = parse_envelope(payload()) + assert envelope["metadata"] == {} # the documented example sends {} + + body = payload() + body["object"]["metadata"] = {"env": "production", "tenant": "acme"} + envelope, _ = parse_envelope(body) + assert envelope["metadata"] == {"env": "production", "tenant": "acme"} + + +def test_metadata_accepts_none(): + # None is the Advanced settings default. + body = payload() + body["object"]["metadata"] = None + envelope, _ = parse_envelope(body) + assert envelope["metadata"] is None + + +def test_metadata_accepts_being_absent(): + body = payload() + del body["object"]["metadata"] + envelope, _ = parse_envelope(body) + assert envelope["metadata"] is None + response = post(body) + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# No HMAC anywhere in the verify path +# --------------------------------------------------------------------------- + + +def test_handler_computes_no_hmac_and_imports_no_hashlib(): + """ + Polytomic sends no signature, so there is nothing to digest. A verifier that + computes an HMAC here is comparing against nothing. + + `hmac.compare_digest` IS allowed — that is a constant-time byte comparison, + not an HMAC. What must not appear is `hmac.new(...)` or any `hashlib` use. + + This walks the AST rather than grepping the text, so the prose comments that + name these functions in order to forbid them don't trip the assertion. + """ + source = open(os.path.join(os.path.dirname(__file__), "main.py")).read() + tree = ast.parse(source) + + for node in ast.walk(tree): + if isinstance(node, ast.Call): + callee = ast.unparse(node.func) + assert callee != "hmac.new", "no HMAC should be computed — there is no signature" + assert not callee.startswith("hashlib."), f"unexpected hashlib call: {callee}" + elif isinstance(node, ast.Import): + assert all(alias.name != "hashlib" for alias in node.names) + elif isinstance(node, ast.ImportFrom): + assert node.module != "hashlib" + + +# --------------------------------------------------------------------------- +# Health check +# --------------------------------------------------------------------------- + + +def test_health_check(): + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/skills/polytomic-webhooks/examples/nextjs/.env.example b/skills/polytomic-webhooks/examples/nextjs/.env.example new file mode 100644 index 00000000..57219043 --- /dev/null +++ b/skills/polytomic-webhooks/examples/nextjs/.env.example @@ -0,0 +1,25 @@ +# REQUIRED. The connection Secret from Polytomic. +# +# Connections -> your Webhook connection -> hover the secret key field to reveal +# its value. Polytomic sends this exact value back on every request as +# Authorization: Bearer +# +# This is NOT a signing secret. Polytomic does not sign its webhooks — there is +# no HMAC, no digest and no signature header. The bearer token is the ONLY +# authentication. If this is unset the handler fails closed with 500. +# +# The value may look like a JWT (the docs' example does). Treat it as an opaque +# string: do not decode it, do not jwt.verify() it. +POLYTOMIC_WEBHOOK_SECRET= + +# OPTIONAL. Freshness window for the Polytomic-Signature-Timestamp header, in +# seconds. Default 300 (the docs suggest rejecting requests "more than a few +# minutes old"). Set to 0 to disable the check entirely. +# +# DEFENCE-IN-DEPTH ONLY. That header is an RFC 3339 timestamp, NOT a signature, +# and it is not covered by any signature — so this proves nothing about +# authenticity. It only limits replay of an old captured request. +POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=300 + +# Optional. Port for the example server. +PORT=3000 diff --git a/skills/polytomic-webhooks/examples/nextjs/README.md b/skills/polytomic-webhooks/examples/nextjs/README.md new file mode 100644 index 00000000..4b0097be --- /dev/null +++ b/skills/polytomic-webhooks/examples/nextjs/README.md @@ -0,0 +1,164 @@ +# Polytomic Webhooks - Next.js Example + +Minimal example of receiving **Polytomic sync record batches** in a Next.js App +Router route handler. + +> **There is no signature to verify.** Polytomic does not sign its webhook +> payloads — no HMAC, no digest, no signing secret, no signature header, no +> `X-Polytomic-*` header of any kind. The **only** authentication is a **static +> shared bearer token** matching the connection Secret. See the skill's +> [references/verification.md](../../references/verification.md). + +> **`Polytomic-Signature-Timestamp` is not a signature.** Despite the name, it +> carries only an RFC 3339 UTC timestamp (`2021-06-01T22:55:36Z`). It is used here +> for an optional freshness check, which is **defence-in-depth only** — the +> timestamp is not covered by any signature, so it proves nothing about +> authenticity. + +## What this example shows + +- `app/webhooks/polytomic/route.ts` — an App Router `POST` handler authenticating + by comparing the `Authorization` bearer token against + `POLYTOMIC_WEBHOOK_SECRET` in **constant time**, stripping exactly one + `Bearer ` prefix (case-insensitively on the scheme, per RFC 7235) and + length-guarding so `crypto.timingSafeEqual` can't throw. +- **Fail closed:** if `POLYTOMIC_WEBHOOK_SECRET` is unset the route returns `500` + and processes nothing. On a provider with no signature, the bearer token is the + *entire* security boundary — "unconfigured" must never mean "accept everything". +- **No HMAC anywhere.** `crypto.createHmac` / `createHash` are not called, and a + test asserts that. There is no signature to compute a digest against. +- **The bearer token is treated as an opaque secret.** The documented sample value + happens to decode as an HS256 JWT, but it is signed with a key you don't hold, + has no `exp`, and the docs call it "a static value" — so no `jwt.verify`, no + `jwt.decode`, no `aud`/`iss`/`exp` checks. A test proves a non-JWT secret works. +- **Optional timestamp freshness** (`POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS`, + default 300, `0` disables) parsed with `new Date()` — **never `parseInt`**, which + would turn `2021-06-01T22:55:36Z` into `2021`. A test asserts an epoch-seconds + value is *not* accepted. +- **Batch handling.** `object.records` is a list (default size 100, + user-configurable), so the handler **always loops**. Tested with a 250-record + batch and an empty one. +- **`records[].fields` is typed `Record`**, not a fixed + interface — the keys are defined by the sync configuration, so a typed model + over `email` / `last_login` would be wrong for every other customer. +- **`object.metadata` may be an object, `null`, or absent** — all three tested. +- **Event dispatch on the single documented event, `sync.records`**, with the + default branch **ignoring unknown events with a 200** (the docs anticipate + future types, and a 4xx would mark the customer's sync run as failed). + +Because there is no signature, there is **no raw-body requirement** — the handler +uses `await request.json()` directly rather than `request.text()`. (Polytomic may +gzip the body; Next.js and your reverse proxy decompress that transparently.) +Malformed JSON becomes a `400`. + +## Prerequisites + +- Node.js 18+ +- A Polytomic account with a **Webhook connection** and a **Model Sync** pointed + at it. A connection alone delivers nothing — the sync is what sends. + +## Setup + +1. Install dependencies: + ```bash + npm install + ``` + +2. Copy environment variables: + ```bash + cp .env.example .env.local + ``` + +3. Add your Polytomic connection Secret as `POLYTOMIC_WEBHOOK_SECRET`. In + Polytomic: **Connections** → your Webhook connection → **hover the secret key + field to reveal its value**. + + There is **no signing secret** to configure — Polytomic has none. This value + is the shared bearer token Polytomic sends back on every request. + +## Run + +```bash +npm run dev +``` + +Server runs on http://localhost:3000, with the webhook at +`POST /webhooks/polytomic`. + +## Test + +```bash +npm test +``` + +Replay the documented payload by hand: + +```bash +curl -X POST http://localhost:3000/webhooks/polytomic \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $POLYTOMIC_WEBHOOK_SECRET" \ + -H "Polytomic-Signature-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -d '{ + "event": "sync.records", + "object": { + "id": "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name": "Webhook HTTP Endpoint sync", + "records": [ + { + "hash": "b7421c6c57bd49f7", + "fields": { "email": "nathan@polytomic.com", "last_login": "2020-12-02T00:00:00Z" } + } + ], + "metadata": {} + } + }' +``` + +Note the timestamp format: **RFC 3339 UTC**, not a Unix epoch integer. + +Polytomic documents **no test event, no handshake and no "send test" button** — to +exercise the real path you must run the sync. Watch the delivery in Polytomic's +**sync history** view (Advanced settings → *Capture webhook requests and +responses*, default on). + +### Receive webhooks locally + +```bash +npx hookdeck-cli listen 3000 polytomic --path /webhooks/polytomic +``` + +No account required — the CLI creates a guest account on first run and gives you +a public HTTPS URL plus a web UI for inspecting requests. Paste that URL into the +Webhook connection, then run your sync. + +Because Polytomic sends no signature, there is nothing for a gateway to verify on +the **Polytomic → gateway** hop. What a gateway adds here is retries and replay +that Polytomic itself does not document — which matters, because a single 5xx +marks the entire sync run failed. + +## Serverless note + +On Vercel and similar platforms the function may be frozen the moment the +response is returned, so work started after `NextResponse.json(...)` is not +guaranteed to finish. Polytomic's contract makes this a real tension: *"Any 4xx or +5xx error will cause the sync to appear as a failure"*, so you want to answer +fast — but you also must not lose the batch. + +Enqueue the batch to a durable queue (or a gateway) **before** responding, and let +a worker do the processing. Returning 200 and then doing the work inline is only +safe on a long-lived server, as in the Express example. + +## Endpoint + +- `POST /webhooks/polytomic` — `500` if `POLYTOMIC_WEBHOOK_SECRET` is unset (fail + closed), `401` on a missing or mismatched bearer token, `400` on a stale or + unparseable `Polytomic-Signature-Timestamp` (when the tolerance is > 0), `400` + on invalid JSON or a malformed envelope, `200` otherwise — including for + unknown event types, which are ignored. + +## A note on the first delivery + +If your first batch is enormous, that is not a bug: *"The first time a Polytomic +Model Sync runs, it will sync everything in the source."* Either enable **Skip +backfill on first sync** in the sync's Advanced settings, or make sure you enqueue +rather than process inline. diff --git a/skills/polytomic-webhooks/examples/nextjs/app/webhooks/polytomic/route.ts b/skills/polytomic-webhooks/examples/nextjs/app/webhooks/polytomic/route.ts new file mode 100644 index 00000000..7944b738 --- /dev/null +++ b/skills/polytomic-webhooks/examples/nextjs/app/webhooks/polytomic/route.ts @@ -0,0 +1,324 @@ +// Generated with: polytomic-webhooks skill +// https://github.com/hookdeck/webhook-skills +import { NextRequest, NextResponse } from 'next/server'; +import crypto from 'crypto'; + +// Polytomic Webhook destination receiver (Next.js App Router). +// +// Polytomic is a data-movement platform. Its one outbound-HTTP surface is the +// Webhook *connection used as a sync destination*: you add a Webhook connection, +// point a Model Sync at it, and Polytomic POSTs BATCHES OF CHANGED RECORDS to +// your URL on the sync's schedule. This is not event subscription — there is no +// event-subscription UI and no per-event-type toggles. +// +// Three things make Polytomic unlike most providers: +// +// 1. THERE IS NO SIGNATURE. No HMAC, no digest, no signing secret, no +// signature header, and no X-Polytomic-* header of any kind. The ONLY +// authentication is a STATIC SHARED BEARER TOKEN matching the connection +// Secret. Verbatim from the docs: "This should be a 'Bearer' token matching +// the same value that was provided as the 'Secret' during connection setup. +// For now, this is the only request authorization and is a static value." +// Note: crypto.createHmac / createHash are called NOWHERE in this file. +// +// 2. `Polytomic-Signature-Timestamp` IS NOT A SIGNATURE, despite its name. It +// carries only an RFC 3339 / ISO 8601 UTC timestamp ("2021-06-01T22:55:36Z") +// — not a Unix epoch integer and not a digest. Parse it with `new Date()`, +// NEVER parseInt(). The freshness check below is defence-in-depth only: the +// timestamp is not covered by any signature, so an attacker holding the +// bearer token can set any value they like. +// +// 3. EVERY PAYLOAD IS A BATCH. `object.records` is "a list of the records +// changed since the last payload" — default batch size 100, and +// user-configurable. We always loop. Never assume one record. +// +// Because there is no signature, there is NO raw-body requirement here — we can +// use `request.json()` directly rather than `request.text()`. (Polytomic may gzip +// the body; Next.js and your reverse proxy decompress that transparently.) + +/** Default freshness window, in seconds — the docs' "more than a few minutes old". */ +const DEFAULT_TOLERANCE_SECONDS = 300; + +/** + * `records[].fields` "contains each of the fields you selected to be delivered" + * — THE KEYS ARE USER-DEFINED by the sync configuration. The `email` / + * `last_login` in the docs' example are that customer's chosen fields, NOT a + * Polytomic schema. So this is deliberately an open record type, never a fixed + * interface. + */ +export type PolytomicFields = Record; + +export interface PolytomicRecord { + /** + * "a computed hash of the record's fields key/values pairs, which may be useful + * for deduplicating incoming data" — i.e. an idempotency key. Its algorithm and + * length are undocumented, so never recompute or assume them, and NEVER use it + * for authentication: it is a digest over data Polytomic is sending you. + */ + hash: string | null; + fields: PolytomicFields; +} + +export interface ParsedEnvelope { + ok: true; + event: string; + /** The UUID of the SYNC — it matches the id in the Polytomic UI's URL bar. */ + syncId: string | null; + /** The sync's name — "useful for discriminating against data coming in from different endpoints." */ + syncName: string | null; + records: unknown[]; + /** May be an object, null, or absent (Advanced settings -> Metadata defaults to null). */ + metadata: Record | null; +} + +export interface ParseError { + ok: false; + error: string; +} + +function webhookSecret(): string { + // Read per request rather than at module load, so the fail-closed path stays + // testable and a restart isn't needed after a rotation. + return process.env.POLYTOMIC_WEBHOOK_SECRET || ''; +} + +function toleranceSeconds(): number { + const raw = process.env.POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS; + if (raw === undefined || raw === '') return DEFAULT_TOLERANCE_SECONDS; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : DEFAULT_TOLERANCE_SECONDS; +} + +/** + * Authenticate the request by comparing the Authorization bearer token against + * the connection Secret, in constant time. + * + * This is the WHOLE of Polytomic's authentication. There is no signature to + * verify: no HMAC is computed here, because no signature header is sent. + * + * The documented sample token happens to decode as an HS256 JWT (claims + * {"aud":"webhook","jti":"","iss":"https://app.polytomic-local.com:8443/"} + * — `iss` is the issuing Polytomic instance, and the docs' example is a local dev + * host). Do NOT treat it as a JWT: it is signed with a key Polytomic does not + * give you, it carries no `exp`, and the docs call it "a static value". Compare + * the whole string byte-for-byte. + * + * @returns null when unconfigured — the caller MUST fail closed with 500. + */ +export function verifyBearerToken( + authorizationHeader: string | null | undefined, + secret: string | undefined +): boolean | null { + if (!secret) return null; // unset => fail closed, never silently accept + if (typeof authorizationHeader !== 'string') return false; + + // Strip exactly ONE leading "Bearer " prefix. The scheme is case-insensitive + // per RFC 7235; the token after it is not. An anchored, non-global replacement + // strips at most one prefix. + const token = authorizationHeader.replace(/^Bearer /i, ''); + + const a = Buffer.from(token); + const b = Buffer.from(secret); + // Length-guard first: crypto.timingSafeEqual throws on unequal buffer lengths, + // and the attacker controls the incoming length. + return a.length === b.length && crypto.timingSafeEqual(a, b); +} + +/** + * Optional freshness check on Polytomic-Signature-Timestamp. + * + * The docs endorse this: "In general, it is a good idea to reject requests older + * than you expect (more than a few minutes old)." But the timestamp is NOT + * covered by any signature, so this proves nothing about authenticity — it only + * limits replay of an OLD CAPTURED REQUEST. + * + * The value is RFC 3339 UTC, e.g. "2021-06-01T22:55:36Z". NEVER parseInt() it: + * parseInt("2021-06-01T22:55:36Z") returns 2021, which looks plausible and + * silently breaks the check. + * + * @param tolerance seconds; <= 0 disables the check + */ +export function timestampIsFresh( + timestampHeader: string | null | undefined, + tolerance: number = toleranceSeconds() +): boolean { + if (tolerance <= 0) return true; // check disabled + if (typeof timestampHeader !== 'string' || timestampHeader.trim() === '') return false; + + const sent = new Date(timestampHeader.trim()); + if (Number.isNaN(sent.getTime())) return false; + + return Math.abs(Date.now() - sent.getTime()) <= tolerance * 1000; +} + +/** + * Defensive validation of the documented envelope. + * + * `object` is "an envelope that will contain the payload, regardless of event" + * and is always present. `metadata` defaults to null in the sync configuration, + * so it may be an object, null, OR ABSENT — all three are handled. + */ +export function parseEnvelope(payload: unknown): ParsedEnvelope | ParseError { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return { ok: false, error: 'payload is not a JSON object' }; + } + + const body = payload as Record; + + if (typeof body.event !== 'string' || body.event === '') { + return { ok: false, error: 'missing or invalid event' }; + } + + const objectValue = body.object; + if (typeof objectValue !== 'object' || objectValue === null || Array.isArray(objectValue)) { + return { ok: false, error: 'missing or invalid object envelope' }; + } + + const envelope = objectValue as Record; + + // `records` is absent on event types we don't know about yet, so it is only + // required for sync.records. + if (envelope.records !== undefined && !Array.isArray(envelope.records)) { + return { ok: false, error: 'object.records is not an array' }; + } + + const metadata = envelope.metadata; + + return { + ok: true, + event: body.event, + syncId: typeof envelope.id === 'string' ? envelope.id : null, + syncName: typeof envelope.name === 'string' ? envelope.name : null, + records: Array.isArray(envelope.records) ? envelope.records : [], + metadata: + typeof metadata === 'object' && metadata !== null && !Array.isArray(metadata) + ? (metadata as Record) + : null, + }; +} + +/** + * Normalize one record from the batch. + * + * `fields` keys come from the user's sync configuration, so access them + * defensively — any key may be absent and any value may be null. + * + * @returns null if the record is unusable + */ +export function normalizeRecord(record: unknown): PolytomicRecord | null { + if (typeof record !== 'object' || record === null || Array.isArray(record)) return null; + + const r = record as Record; + const rawFields = r.fields; + + const fields: PolytomicFields = + typeof rawFields === 'object' && rawFields !== null && !Array.isArray(rawFields) + ? (rawFields as PolytomicFields) + : {}; + + return { + hash: typeof r.hash === 'string' && r.hash !== '' ? r.hash : null, + fields, + }; +} + +/** + * Handle the sync.records batch. + * + * ALWAYS LOOPS. Default batch size is 100 ("Webhook batch size (default: 100)" + * under the sync's Advanced settings) and it is user-configurable, so never + * assume one record and write for large batches. + * + * In production, hand this to a queue — the route returns 200 first. A 4xx/5xx + * "will cause the sync to appear as a failure", so a slow downstream would fail + * your customer's whole sync run. + */ +function handleSyncRecords(envelope: ParsedEnvelope): void { + console.log( + `Polytomic sync.records: sync "${envelope.syncName ?? 'unknown'}" ` + + `(${envelope.syncId ?? 'no id'}) delivered ${envelope.records.length} record(s)` + ); + + if (envelope.metadata) { + // Hardcoded key/values from Advanced settings -> Metadata (default: null). + console.log(` metadata: ${JSON.stringify(envelope.metadata)}`); + } + + for (const raw of envelope.records) { + const record = normalizeRecord(raw); + if (!record) { + console.warn(' skipping malformed record in batch'); + continue; + } + + // TODO: replace with your own processing, and dedupe on + // `${envelope.syncId}:${record.hash}` so a redelivered batch is a no-op. + const fieldNames = Object.keys(record.fields); + console.log( + ` record ${record.hash ?? '(no hash)'}: ${fieldNames.length} field(s) ` + + `[${fieldNames.join(', ')}]` + ); + } +} + +export async function POST(request: NextRequest) { + // 1. Fail closed when no secret is configured. On a provider with NO + // signature, the bearer token is the entire security boundary — treating + // "unconfigured" as "accept everything" leaves a fully open endpoint that + // looks secure. + const authResult = verifyBearerToken( + request.headers.get('authorization'), + webhookSecret() + ); + if (authResult === null) { + console.error('Polytomic webhook refused: POLYTOMIC_WEBHOOK_SECRET is not set'); + return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 }); + } + if (authResult === false) { + console.error('Polytomic webhook rejected: bearer token mismatch'); + return NextResponse.json({ error: 'Invalid bearer token' }, { status: 401 }); + } + + // 2. Optional freshness check. Defence-in-depth only — see timestampIsFresh. + if (!timestampIsFresh(request.headers.get('polytomic-signature-timestamp'))) { + console.error('Polytomic webhook rejected: stale or unparseable timestamp'); + return NextResponse.json({ error: 'Stale or invalid timestamp' }, { status: 400 }); + } + + // 3. Parse the body. No signature means no raw-body requirement. + let payload: unknown; + try { + payload = await request.json(); + } catch { + console.error('Polytomic webhook rejected: invalid JSON body'); + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + const envelope = parseEnvelope(payload); + if (!envelope.ok) { + console.error(`Polytomic webhook rejected: ${envelope.error}`); + return NextResponse.json({ error: envelope.error }, { status: 400 }); + } + + // 4. Dispatch on the event type. There is EXACTLY ONE documented event: + // sync.records. "You should only process webhooks you know about—for right + // now, that is just the sync.records event." + // + // In a real deployment, enqueue here and let the worker do the work, so the + // 200 below is returned immediately. "On receipt of the payload, your API + // should return 200 OK. Any 4xx or 5xx error will cause the sync to appear + // as a failure", and no retry policy is documented. + switch (envelope.event) { + case 'sync.records': + handleSyncRecords(envelope); + break; + + default: + // IGNORE unknown events — do NOT error. The docs explicitly anticipate + // future event types, and a 4xx would mark the customer's sync run failed. + console.log(`Polytomic webhook: ignoring unknown event "${envelope.event}"`); + break; + } + + return NextResponse.json({ received: true }, { status: 200 }); +} diff --git a/skills/polytomic-webhooks/examples/nextjs/package.json b/skills/polytomic-webhooks/examples/nextjs/package.json new file mode 100644 index 00000000..6cbc14b4 --- /dev/null +++ b/skills/polytomic-webhooks/examples/nextjs/package.json @@ -0,0 +1,22 @@ +{ + "name": "polytomic-webhooks-nextjs", + "version": "1.0.0", + "description": "Polytomic sync record batch webhook handler with Next.js App Router (no signature — static bearer token)", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "dependencies": { + "next": "^16.3.8", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "typescript": "^7.0.2", + "vitest": "^5.0.3" + } +} diff --git a/skills/polytomic-webhooks/examples/nextjs/test/webhook.test.ts b/skills/polytomic-webhooks/examples/nextjs/test/webhook.test.ts new file mode 100644 index 00000000..360529e1 --- /dev/null +++ b/skills/polytomic-webhooks/examples/nextjs/test/webhook.test.ts @@ -0,0 +1,379 @@ +// Generated with: polytomic-webhooks skill +// https://github.com/hookdeck/webhook-skills +import { describe, it, expect, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { + POST, + verifyBearerToken, + timestampIsFresh, + parseEnvelope, + normalizeRecord, +} from '../app/webhooks/polytomic/route'; + +// Polytomic does NOT sign its webhooks, so there are NO SIGNATURES TO GENERATE +// in these tests — no HMAC, no digest, nothing to compute. The only credential +// is the static shared bearer token (the connection Secret), which Polytomic +// echoes back verbatim in the Authorization header. +// +// This value mirrors the shape of the token in Polytomic's documented example, +// which happens to decode as an HS256 JWT with claims +// {"aud":"webhook","jti":"","iss":"https://app.polytomic-local.com:8443/"}. +// That is an observation about the documented example, not a documented format — +// the handler treats the whole string as an OPAQUE SECRET and never decodes it. +const SECRET = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + + 'eyJhdWQiOiJ3ZWJob29rIiwianRpIjoiMDAwMDAwMDAtMDAwMC0wMDAwLTAwMDAtMDAwMDAwMDAwMDAwIiwiaXNzIjoiaHR0cHM6Ly9hcHAucG9seXRvbWljLWxvY2FsLmNvbTo4NDQzLyJ9.' + + 'FBSU_fC1YFyWhMSPErRono4BPfkIeT3MkRdZrepiP3c'; +const WRONG_SECRET = SECRET.slice(0, -1) + 'X'; // same length, last char differs + +process.env.POLYTOMIC_WEBHOOK_SECRET = SECRET; + +const URL = 'http://localhost:3000/webhooks/polytomic'; + +/** RFC 3339 / ISO 8601 UTC, e.g. "2021-06-01T22:55:36Z" — the documented format. */ +function rfc3339Now(offsetSeconds = 0): string { + return new Date(Date.now() + offsetSeconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +/** + * The documented payload, verbatim from + * https://docs.polytomic.com/docs/webhooks-connections + * + * `fields` holds `email` / `last_login` because that is what THAT customer's sync + * selected — the keys are user-defined, not a Polytomic schema. + */ +function documentedPayload(): Record { + return { + event: 'sync.records', + object: { + id: '1ea8f90a-b22e-4218-86d5-c3c109e1fbb7', + name: 'Webhook HTTP Endpoint sync', + records: [ + { + hash: 'b7421c6c57bd49f7', + fields: { + email: 'nathan@polytomic.com', + last_login: '2020-12-02T00:00:00Z', + }, + }, + ], + metadata: {}, + }, + }; +} + +interface RequestOptions { + secret?: string | null; + timestamp?: string | null; + raw?: string; +} + +function makeRequest(payload: Record | null, options: RequestOptions = {}) { + const { secret = SECRET, timestamp = rfc3339Now(), raw } = options; + const headers: Record = { 'Content-Type': 'application/json' }; + if (secret !== null) headers['Authorization'] = `Bearer ${secret}`; + if (timestamp !== null) headers['Polytomic-Signature-Timestamp'] = timestamp; + + return new NextRequest(URL, { + method: 'POST', + headers, + body: raw !== undefined ? raw : JSON.stringify(payload), + }); +} + +afterEach(() => { + process.env.POLYTOMIC_WEBHOOK_SECRET = SECRET; + delete process.env.POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS; +}); + +describe('bearer token authentication (there is no signature)', () => { + it('accepts the documented payload with a matching bearer token', async () => { + const res = await POST(makeRequest(documentedPayload())); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ received: true }); + }); + + it('rejects a mismatched token with 401', async () => { + const res = await POST(makeRequest(documentedPayload(), { secret: WRONG_SECRET })); + expect(res.status).toBe(401); + }); + + it('rejects a missing Authorization header with 401', async () => { + const res = await POST(makeRequest(documentedPayload(), { secret: null })); + expect(res.status).toBe(401); + }); + + it('fails CLOSED with 500 when POLYTOMIC_WEBHOOK_SECRET is unset', async () => { + // On a provider with no signature, the bearer token is the entire security + // boundary. "Unconfigured" must never mean "accept everything". + delete process.env.POLYTOMIC_WEBHOOK_SECRET; + const res = await POST(makeRequest(documentedPayload())); + expect(res.status).toBe(500); + }); + + it('strips exactly one "Bearer " prefix, case-insensitively on the scheme', () => { + // RFC 7235: the scheme is case-insensitive. + expect(verifyBearerToken(`Bearer ${SECRET}`, SECRET)).toBe(true); + expect(verifyBearerToken(`bearer ${SECRET}`, SECRET)).toBe(true); + expect(verifyBearerToken(`BEARER ${SECRET}`, SECRET)).toBe(true); + // Only ONE prefix is stripped, so a doubled scheme must not authenticate. + expect(verifyBearerToken(`Bearer Bearer ${SECRET}`, SECRET)).toBe(false); + }); + + it('accepts a bare token with no scheme', () => { + expect(verifyBearerToken(SECRET, SECRET)).toBe(true); + }); + + it('compares the token case-SENSITIVELY', () => { + expect(verifyBearerToken(`Bearer ${SECRET.toUpperCase()}`, SECRET)).toBe(false); + }); + + it('returns false rather than throwing on a length mismatch', () => { + // crypto.timingSafeEqual throws on unequal buffer lengths, and the attacker + // controls the incoming length — the length guard must come first. + expect(verifyBearerToken('Bearer short', SECRET)).toBe(false); + expect(verifyBearerToken(`Bearer ${SECRET}extra`, SECRET)).toBe(false); + }); + + it('returns null (not false) when the secret is unset, so callers can fail closed', () => { + expect(verifyBearerToken(`Bearer ${SECRET}`, '')).toBe(null); + expect(verifyBearerToken(`Bearer ${SECRET}`, undefined)).toBe(null); + }); + + it('returns false for a missing Authorization value', () => { + expect(verifyBearerToken(null, SECRET)).toBe(false); + expect(verifyBearerToken(undefined, SECRET)).toBe(false); + }); + + it('does not treat the token as a JWT', () => { + // A JWT-aware verifier would reject this or crash. An opaque byte-for-byte + // comparison accepts it, which is correct: real workspace secrets need not + // be JWTs at all. + const opaque = 'not-a-jwt-at-all-just-an-opaque-shared-secret'; + expect(verifyBearerToken(`Bearer ${opaque}`, opaque)).toBe(true); + }); +}); + +describe('Polytomic-Signature-Timestamp (a timestamp, NOT a signature)', () => { + it('accepts a fresh RFC 3339 timestamp', () => { + expect(timestampIsFresh(rfc3339Now(), 300)).toBe(true); + }); + + it('parses the documented format', () => { + // The docs' example: "2021-06-01T22:55:36Z" — parseable, but long stale. + expect(timestampIsFresh('2021-06-01T22:55:36Z', 0)).toBe(true); // check disabled + expect(timestampIsFresh('2021-06-01T22:55:36Z', 300)).toBe(false); // genuinely stale + }); + + it('rejects a timestamp older than the tolerance', () => { + expect(timestampIsFresh(rfc3339Now(-600), 300)).toBe(false); + }); + + it('rejects a timestamp too far in the future', () => { + expect(timestampIsFresh(rfc3339Now(600), 300)).toBe(false); + }); + + it('is NOT parsed as a Unix epoch integer', () => { + // parseInt("2021-06-01T22:55:36Z") === 2021 — a silent, catastrophic bug. + const epochSeconds = String(Math.floor(Date.now() / 1000)); + expect(timestampIsFresh(epochSeconds, 300)).toBe(false); + }); + + it('rejects an unparseable value', () => { + expect(timestampIsFresh('not-a-timestamp', 300)).toBe(false); + expect(timestampIsFresh('', 300)).toBe(false); + expect(timestampIsFresh(null, 300)).toBe(false); + }); + + it('skips the check entirely when the tolerance is 0 or negative', () => { + expect(timestampIsFresh(null, 0)).toBe(true); + expect(timestampIsFresh('garbage', 0)).toBe(true); + expect(timestampIsFresh('garbage', -1)).toBe(true); + }); + + it('rejects a stale delivery with 400 over HTTP', async () => { + const res = await POST(makeRequest(documentedPayload(), { timestamp: rfc3339Now(-3600) })); + expect(res.status).toBe(400); + }); + + it('authenticates before checking freshness (a bad token is 401, not 400)', async () => { + const res = await POST( + makeRequest(documentedPayload(), { secret: WRONG_SECRET, timestamp: rfc3339Now(-3600) }) + ); + expect(res.status).toBe(401); + }); + + it('honours POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=0 over HTTP', async () => { + process.env.POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS = '0'; + const res = await POST( + makeRequest(documentedPayload(), { timestamp: '2021-06-01T22:55:36Z' }) + ); + expect(res.status).toBe(200); + }); +}); + +describe('event dispatch (exactly one documented event: sync.records)', () => { + it('processes sync.records', async () => { + const res = await POST(makeRequest(documentedPayload())); + expect(res.status).toBe(200); + }); + + it('IGNORES an unknown event with 200, rather than erroring', async () => { + // The docs anticipate future event types. A 4xx/5xx "will cause the sync to + // appear as a failure", so unknown events must be acknowledged. + const payload = documentedPayload(); + payload.event = 'some.future.event'; + delete payload.object.records; + const res = await POST(makeRequest(payload)); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ received: true }); + }); + + it('rejects a missing event field with 400', async () => { + const payload = documentedPayload(); + delete payload.event; + const res = await POST(makeRequest(payload)); + expect(res.status).toBe(400); + }); + + it('rejects a missing object envelope with 400', async () => { + const res = await POST(makeRequest({ event: 'sync.records' })); + expect(res.status).toBe(400); + }); + + it('rejects invalid JSON with 400', async () => { + const res = await POST(makeRequest(null, { raw: '{not json' })); + expect(res.status).toBe(400); + }); +}); + +describe('the batch (object.records is a LIST, default size 100)', () => { + it('handles a multi-record batch', async () => { + const payload = documentedPayload(); + payload.object.records = Array.from({ length: 250 }, (_, i) => ({ + hash: `hash${i}`, + fields: { email: `user${i}@example.com`, last_login: '2020-12-02T00:00:00Z' }, + })); + const res = await POST(makeRequest(payload)); + expect(res.status).toBe(200); + + const parsed = parseEnvelope(payload); + expect(parsed.ok).toBe(true); + // Batch size is user-configurable, so a handler must never assume 1. + if (parsed.ok) expect(parsed.records).toHaveLength(250); + }); + + it('handles an empty batch', async () => { + const payload = documentedPayload(); + payload.object.records = []; + const res = await POST(makeRequest(payload)); + expect(res.status).toBe(200); + }); + + it('rejects a non-array records value with 400', async () => { + const payload = documentedPayload(); + payload.object.records = 'not-an-array'; + const res = await POST(makeRequest(payload)); + expect(res.status).toBe(400); + }); + + it('exposes hash and the sync id for idempotency', () => { + const parsed = parseEnvelope(documentedPayload()); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.syncId).toBe('1ea8f90a-b22e-4218-86d5-c3c109e1fbb7'); + expect(parsed.syncName).toBe('Webhook HTTP Endpoint sync'); + // Dedupe key: `${syncId}:${hash}`. hash is a CONTENT digest for dedupe only + // — never a credential. + expect(normalizeRecord(parsed.records[0])?.hash).toBe('b7421c6c57bd49f7'); + }); +}); + +describe('records[].fields has user-defined keys', () => { + it('passes through whatever keys the sync selected', () => { + // A completely different sync configuration — no `email`, no `last_login`. + const record = normalizeRecord({ + hash: 'abc123', + fields: { account_id: 42, mrr: 199.5, is_churned: false, plan: null }, + }); + expect(Object.keys(record!.fields).sort()).toEqual([ + 'account_id', + 'is_churned', + 'mrr', + 'plan', + ]); + // Null values survive — the handler must tolerate them, not assume strings. + expect(record!.fields.plan).toBe(null); + }); + + it('defaults fields to an empty object when absent or malformed', () => { + expect(normalizeRecord({ hash: 'h' })!.fields).toEqual({}); + expect(normalizeRecord({ hash: 'h', fields: null })!.fields).toEqual({}); + expect(normalizeRecord({ hash: 'h', fields: 'nope' })!.fields).toEqual({}); + expect(normalizeRecord({ hash: 'h', fields: [] })!.fields).toEqual({}); + }); + + it('tolerates a record with no hash', () => { + expect(normalizeRecord({ fields: { a: 1 } })!.hash).toBe(null); + }); + + it('returns null for a non-object record', () => { + expect(normalizeRecord(null)).toBe(null); + expect(normalizeRecord('nope')).toBe(null); + expect(normalizeRecord([])).toBe(null); + }); + + it('does not drop a batch because one record is malformed', async () => { + const payload = documentedPayload(); + payload.object.records = [documentedPayload().object.records[0], null, 'nope']; + const res = await POST(makeRequest(payload)); + expect(res.status).toBe(200); + }); +}); + +describe('object.metadata may be an object, null, or absent', () => { + it('accepts an object (the documented example sends {})', () => { + const base = parseEnvelope(documentedPayload()); + expect(base.ok && base.metadata).toEqual({}); + + const payload = documentedPayload(); + payload.object.metadata = { env: 'production', tenant: 'acme' }; + const parsed = parseEnvelope(payload); + expect(parsed.ok && parsed.metadata).toEqual({ env: 'production', tenant: 'acme' }); + }); + + it('accepts null (the Advanced settings default)', () => { + const payload = documentedPayload(); + payload.object.metadata = null; + const parsed = parseEnvelope(payload); + expect(parsed.ok).toBe(true); + if (parsed.ok) expect(parsed.metadata).toBe(null); + }); + + it('accepts it being absent entirely', async () => { + const payload = documentedPayload(); + delete payload.object.metadata; + const parsed = parseEnvelope(payload); + if (parsed.ok) expect(parsed.metadata).toBe(null); + const res = await POST(makeRequest(payload)); + expect(res.status).toBe(200); + }); +}); + +describe('no HMAC anywhere in the verify path', () => { + it('the route source contains no hashing calls', () => { + // Polytomic sends no signature, so there is nothing to digest. A verifier + // that computes an HMAC here is comparing against nothing. + // + // Match CALLS (with the paren), so the prose comments that name these + // functions in order to forbid them don't trip the assertion. + const source = readFileSync( + resolve(__dirname, '../app/webhooks/polytomic/route.ts'), + 'utf8' + ); + expect(source).not.toMatch(/createHmac\s*\(/); + expect(source).not.toMatch(/createHash\s*\(/); + }); +}); diff --git a/skills/polytomic-webhooks/examples/nextjs/vitest.config.ts b/skills/polytomic-webhooks/examples/nextjs/vitest.config.ts new file mode 100644 index 00000000..8e730d50 --- /dev/null +++ b/skills/polytomic-webhooks/examples/nextjs/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/skills/polytomic-webhooks/references/overview.md b/skills/polytomic-webhooks/references/overview.md new file mode 100644 index 00000000..71ebd528 --- /dev/null +++ b/skills/polytomic-webhooks/references/overview.md @@ -0,0 +1,165 @@ +# Polytomic Webhooks Overview + +## What Are Polytomic Webhooks? + +**Polytomic** (polytomic.com) is a data-movement platform: you build **models** +over a source, then run **Model Syncs** and **Bulk Syncs** that move data between +warehouses, SaaS apps and databases (reverse ETL). + +Polytomic's single outbound-HTTP surface is the **Webhook connection used as a +sync destination** — "Webhooks — Destination" in the docs nav, and the only +webhook-related page in Polytomic's docs. The model is: + +1. You add a **Webhook connection** (a destination), giving Polytomic a URL. +2. Polytomic gives you a **Secret**. +3. You point a **Model Sync** at that connection and select the fields you want. +4. On the sync's schedule, Polytomic **POSTs batches of changed records** to your + URL. + +**This is not event subscription.** There is no event-subscription UI, no +per-event-type toggles and no separate "platform events" API. Polytomic is not an +event producer in the usual sense — think *"receive Polytomic sync record +batches"*, not *"subscribe to Polytomic events"*. + +## Common Event Types + +There is **exactly one documented event type**. + +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `sync.records` | A Model Sync run has records to deliver — *"a list of the records changed since the last payload"* | Upserting synced rows into your own API/database, triggering downstream workflows per changed record, maintaining a mirror of a warehouse table | + +Verbatim from the docs: *"This is an event type to help you distinguish new and +future hooks. You should only process webhooks you know about—for right now, that +is just the sync.records event."* + +**Consequences for your router:** + +- Switch on the top-level `event` field. +- Handle `sync.records`. +- Make the **default branch ignore unknown events and return 200**. The docs + explicitly anticipate future types, and a 4xx/5xx would mark your customer's + sync run as failed. + +**Do not invent event names.** There is no `sync.started`, `sync.completed`, +`sync.failed`, `record.created` or anything else. None are documented. + +## Event Payload Structure + +The documented envelope, verbatim: + +```json +{ + "event": "sync.records", + "object": { + "id": "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name": "Webhook HTTP Endpoint sync", + "records": [ + { + "hash": "b7421c6c57bd49f7", + "fields": { + "email": "nathan@polytomic.com", + "last_login": "2020-12-02T00:00:00Z" + } + } + ], + "metadata": { } + } +} +``` + +### Field by field + +**`event`** — the event-type discriminator. Only `sync.records` is documented. + +**`object`** — *"an envelope that will contain the payload, regardless of +event."* Always present. + +**`object.id`** — the UUID of the **sync**, not of the delivery or the record. +*"It will match the value seen the URL bar when you have the corresponding sync +configuration open."* Useful for attributing a batch to a sync configuration. + +**`object.name`** — the sync's name. *"This name matches the sync setup you +created in Polytomic. It can be useful for discriminating against data coming in +from different endpoints."* + +**`object.records`** — *"a list of the records changed since the last payload."* + +> **THE PAYLOAD IS A BATCH, NOT A SINGLE RECORD.** Every handler must loop. The +> default batch size is **100** ("Webhook batch size (default: 100)" under +> Advanced settings) and it is **user-configurable**, so never assume 1, and +> write for large batches. + +**`object.records[].hash`** — *"a computed hash of the record's fields key/values +pairs, which may be useful for deduplicating incoming data."* + +- Use it as an **idempotency key** (ideally scoped by `object.id`). +- **Never use it for authentication.** It is a content digest of the record, + computed by Polytomic over data it is sending you — it proves nothing about the + sender. +- The docs do **not** document its algorithm or length. `b7421c6c57bd49f7` is + just an example; don't assume 16 hex characters, and don't try to recompute it. + +**`object.records[].fields`** — *"contains each of the fields you selected to be +delivered."* + +> **THE KEYS ARE USER-DEFINED.** `email` and `last_login` in the example are that +> customer's chosen fields, not a Polytomic schema. Your `fields` object has +> whatever keys *your* sync configuration selected. + +Therefore: + +- **Do not define a fixed typed model** over `fields`. Type it as + `Record` / `dict[str, Any]`. +- **Access defensively.** Any key may be absent; any value may be `null`. +- Validate the handful of keys you actually depend on, and tolerate the rest. + +**`object.metadata`** — *"Any key-value pairs of metadata defined in the sync +configuration."* This comes from the **Advanced settings → Metadata** option, +whose default is `null`. + +> So `metadata` may be **an object, `null`, or absent entirely**. Handle all +> three. The documented example shows `{ }`. + +## Delivery Semantics + +Several sync-level settings change what arrives, and they matter more than they +look: + +- **Syncs are differential by default.** `records` normally holds only + changes since the last payload. **"Always do a full sync" (default false)** + re-delivers everything on every run. +- **The first run backfills the entire source.** *"The first time a Polytomic + Model Sync runs, it will sync everything in the source."* **"Skip backfill on + first sync" (default false)** turns that off. This is the single most common + cause of an unexpectedly huge first batch. +- **Batch size is configurable** (default 100). + +## Response Contract + +Verbatim: *"On receipt of the payload, your API should return `200 OK`. Any 4xx or +5xx error will cause the sync to appear as a failure."* + +- **No automatic retry policy and no retry schedule is documented.** Do not claim + exponential backoff or a retry count — none is published. +- A non-2xx marks the **sync run** failed in Polytomic's sync history. Recovery + is operational: fix the endpoint, then re-run or wait for the next schedule. +- Because failure is **sync-level**, **acknowledge with 200 immediately and + process the batch asynchronously** — a slow downstream otherwise fails your + customer's sync. +- **Still handle deliveries idempotently** (dedupe on `object.id` + + `records[].hash`). Absent a documented retry policy you cannot assume + at-most-once delivery either. + +## Authentication, In One Line + +**There is no signature.** The only authentication is a **static shared bearer +token** in `Authorization`, matching the connection Secret. See +[verification.md](verification.md). + +## Full Event Reference + +Polytomic documents webhooks on a single page: +[Webhooks — Destination](https://docs.polytomic.com/docs/webhooks-connections). +Every docs page is also retrievable as markdown by appending `.md` +(e.g. [webhooks-connections.md](https://docs.polytomic.com/docs/webhooks-connections.md)). diff --git a/skills/polytomic-webhooks/references/setup.md b/skills/polytomic-webhooks/references/setup.md new file mode 100644 index 00000000..77ae5203 --- /dev/null +++ b/skills/polytomic-webhooks/references/setup.md @@ -0,0 +1,209 @@ +# Setting Up Polytomic Webhooks + +## Prerequisites + +- A Polytomic account with permission to create **Connections** and **Syncs** +- A publicly reachable HTTPS endpoint (or a tunnel — see + [Receiving webhooks locally](#receiving-webhooks-locally)) + +## How the Pieces Fit + +Polytomic's webhook surface is a **destination connection**, not an event +subscription. Two objects are involved: + +1. **A Webhook connection** — holds the delivery URL and the Secret. +2. **A Model Sync** — holds the source model, the selected fields, the schedule, + and the Advanced settings. The sync is what actually sends. + +A connection on its own delivers nothing. You need a sync pointed at it. + +## Create the Webhook Connection + +Verbatim steps from the docs: + +1. In Polytomic, go to **Connections** → **Add Connection** → **Webhook**. +2. Enter the URL that you'd like Polytomic to deliver payloads to. +3. *"Polytomic will give you a secret key that you will be able to use to verify + incoming payloads with. Hovering over the secret key field will reveal its + value."* + +## Get Your Secret + +**Hover over the secret key field in the connection to reveal its value.** + +Important framing, because the docs' wording invites a wrong implementation: + +- The docs call it a key you can *"verify incoming payloads with"*, but there is + **no signing and no HMAC**. Polytomic simply sends this exact value back to you + as `Authorization: Bearer ` on every request. +- So it is a **shared bearer credential**, not a signing secret. Store it as + `POLYTOMIC_WEBHOOK_SECRET` and compare it in constant time. See + [verification.md](verification.md). +- The value observed in the docs' example looks like a JWT. **Treat it as an + opaque string** — do not parse or validate it. + +## Create the Sync + +Once the connection exists, create a Model Sync targeting it and **select the +fields you want delivered**. Those selections become the keys inside +`object.records[].fields` — which is exactly why `fields` has no fixed schema. + +`object.name` will be the sync's name, and `object.id` its UUID — *"It will match +the value seen the URL bar when you have the corresponding sync configuration +open."* Both are useful when several syncs post to the same endpoint. + +## No Handshake or Validation Request + +**No handshake, challenge, echo or validation request is documented.** Polytomic +does not ping your URL to confirm it when you create the connection, and there is +no "send test event" button documented. To exercise the real path, run the sync. + +## Advanced Settings + +At the bottom of the sync configuration is an **Advanced settings** section. +Each of these changes delivery semantics: + +### Webhook batch size (default: 100) + +*"Polytomic's default batch size when sending to your webhook is 100. You can +override this with a webhook batch size of your choice."* + +Your handler must **loop over `object.records`** and must not assume one record. +Because the value is user-configurable, write for large batches too — acknowledge +with 200 first, then process. + +### Metadata (default: null) + +*"You can specify extra hardcoded keys and values for Polytomic to include in its +webhook payload."* These appear as `object.metadata`. + +Because the default is `null`, **`metadata` may be an object, `null`, or absent +entirely**. Handle all three. Useful for tagging which environment or tenant a +sync belongs to. + +### Capture webhook requests and responses (default: true) + +*"Leaving this on means you'll get to see a log of each request and response in +the Polytomic sync history view."* + +**This is your primary debugging surface.** When a delivery 401s or 500s, open +the sync history to see the exact request Polytomic sent and the response it got +back. Note that it logs request bodies, so consider whether that is appropriate +for sensitive fields. + +### Always do a full sync (default: false) + +*"Polytomic's Model Syncs are by default differential: only updates/new rows are +synced. You can turn this off and have Polytomic always sync everything in the +source."* + +With the default (off), `records` holds only changes. Turning it on re-delivers +**everything on every run** — a much heavier load, and a reason to make your +handler idempotent on `records[].hash`. + +### Skip backfill on first sync (default: false) + +*"The first time a Polytomic Model Sync runs, it will sync everything in the +source. Subsequent syncs will be automatically differential. You can have +Polytomic skip the initial backfill and only sync diffs going forward by setting +this to true."* + +> **This is the single most common cause of an unexpectedly huge first batch.** If +> your first webhook delivery floods your endpoint, this is almost certainly why. +> Either enable this setting, or make sure the endpoint acknowledges fast and +> queues the work. + +## Firewall: Source IPs + +If your API sits behind a firewall, the webhooks page points you at +[docs.polytomic.com/docs/whitelist-ips](https://docs.polytomic.com/docs/whitelist-ips): + +``` +54.190.82.25 +44.232.40.21 +35.155.106.54 +54.200.67.134 +44.224.213.129 +54.149.95.139 +``` + +Three honest caveats: + +1. **That page is framed around a different purpose** — it describes the IPs for + *"integrating Polytomic with databases, data warehouses, and cloud storage"*. + The webhooks page merely links to it for webhook traffic. Present it as **the + allowlist Polytomic points you at**, not as a documented webhook-egress range. +2. **It does not apply to self-hosted / on-premise Polytomic.** Verbatim from + that page: *"If you're hosting Polytomic yourself, note that the IP addresses + below don't apply to you."* Allowlist your own infrastructure's addresses + instead. +3. **It is a firewall convenience, not authentication.** Source IPs can't + establish that a request came from your Polytomic workspace. Keep the bearer + token check regardless. + +## Environment Variables + +```bash +# REQUIRED. The connection Secret (hover the secret key field to reveal it). +# Polytomic sends it back verbatim as `Authorization: Bearer `. +# NOT a signing secret — there is no HMAC. Unset => handler fails closed (500). +POLYTOMIC_WEBHOOK_SECRET= + +# OPTIONAL. Freshness window for Polytomic-Signature-Timestamp, in seconds. +# Default 300. Set to 0 to disable. Defence-in-depth only. +POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=300 +``` + +## Receiving Webhooks Locally + +```bash +npx hookdeck-cli listen 3000 polytomic --path /webhooks/polytomic +``` + +(Use `8000` instead of `3000` for the FastAPI example.) + +No account required — the CLI creates a guest account on first run and gives you a +public HTTPS URL plus a web UI for inspecting requests. Paste that URL into the +Webhook connection, then run your sync. + +Because Polytomic sends no signature, there is nothing for a gateway to verify on +the **Polytomic → gateway** hop. What a gateway adds here is an unguessable +source URL, plus the retries and replay Polytomic itself does not document — which +matters, because a single 5xx marks the whole sync run failed. + +## Rotating the Secret + +The docs do not document a rotation flow or a dual-secret overlap window. Treat +rotation as: update the connection's Secret in Polytomic, then update +`POLYTOMIC_WEBHOOK_SECRET` — and expect a window where deliveries 401 (and +therefore the sync shows failures) unless your handler temporarily accepts both +the old and new value. Checking two candidate secrets in constant time is a +reasonable local pattern; it is not something Polytomic documents. + +## Testing Your Endpoint by Hand + +There is no documented test event, so replay the documented payload yourself: + +```bash +curl -X POST http://localhost:3000/webhooks/polytomic \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $POLYTOMIC_WEBHOOK_SECRET" \ + -H "Polytomic-Signature-Timestamp: $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + -d '{ + "event": "sync.records", + "object": { + "id": "1ea8f90a-b22e-4218-86d5-c3c109e1fbb7", + "name": "Webhook HTTP Endpoint sync", + "records": [ + { + "hash": "b7421c6c57bd49f7", + "fields": { "email": "nathan@polytomic.com", "last_login": "2020-12-02T00:00:00Z" } + } + ], + "metadata": {} + } + }' +``` + +Note the timestamp format: **RFC 3339 UTC**, e.g. `2021-06-01T22:55:36Z` — not a +Unix epoch integer. diff --git a/skills/polytomic-webhooks/references/verification.md b/skills/polytomic-webhooks/references/verification.md new file mode 100644 index 00000000..70e30782 --- /dev/null +++ b/skills/polytomic-webhooks/references/verification.md @@ -0,0 +1,307 @@ +# Polytomic Webhook Verification + +## How It Works: There Is No Signature + +**Polytomic does not sign its webhook payloads.** There is no HMAC, no digest, no +signature header and no signing secret. + +The **only** authentication is a **static shared bearer token**. Verbatim from the +docs, under the `Authorization` header heading: + +> *"This should be a 'Bearer' token matching the same value that was provided as +> the 'Secret' during connection setup. For now, this is the only request +> authorization and is a static value."* + +So the whole verification task is: **compare the incoming `Authorization` header +against the stored connection Secret, in constant time.** + +### What NOT to write + +- **No `crypto.createHmac` / `crypto.createHash` / `hmac.new` / `hashlib.*` + anywhere in the verify path.** There is nothing to HMAC — no signature header + exists to compare a digest against. +- **Do not invent a signature header.** The documented header list (below) is + complete. There is no `X-Polytomic-Signature`, no `X-Polytomic-*` header of any + kind, and Polytomic does not follow the Standard Webhooks spec (`webhook-id` / + `webhook-timestamp` / `webhook-signature`). +- **Do not HMAC the timestamp plus the body "because the timestamp header is + there."** See the next section. + +## The Trap: `Polytomic-Signature-Timestamp` Is Not a Signature + +Despite the word "Signature" in its name, this header carries **only a timestamp**. + +- Format: **RFC 3339 / ISO 8601 UTC**. Documented example: `2021-06-01T22:55:36Z`. +- It is **not** a Unix epoch integer. `parseInt("2021-06-01T22:55:36Z")` yields + `2021` — a silent, catastrophic bug. +- It is **not** a digest of anything. + +The docs say: + +> *"This signature lets your backend know when the request was created. In the +> future it may be used in combination with message signing to provide security. +> In general, it is a good idea to reject requests older than you expect (more +> than a few minutes old)."* + +So parse it with `new Date(value)` (Node) or `datetime.fromisoformat` (Python — +handle the trailing `Z`; see the gotcha below), and use it for a **freshness +check only**. + +### A freshness window is legitimate — and proves nothing about authenticity + +Implementing the staleness check is doc-endorsed **defence-in-depth**, and the +examples here do it as an **optional, configurable tolerance defaulting to 300 +seconds** (the docs' "more than a few minutes old"). + +Be explicit with readers about its limits: + +> **The timestamp is not covered by any signature.** An attacker who has the +> bearer token can set any timestamp they like. The window defends against replay +> of an *old captured request* — nothing more. It is not a substitute for, or an +> upgrade to, the bearer check. + +## Documented Headers (Complete List) + +In the docs' own casing: + +| Header | Documented value | Role | +|--------|------------------|------| +| `Authorization` | `Bearer ` | **The only authentication.** | +| `Polytomic-Signature-Timestamp` | `2021-06-01T22:55:36Z` | RFC 3339 UTC timestamp. Freshness only. | +| `Content-Type` | `application/json` | *"Polytomic delivers its webhooks payloads as json only. This header will always be present."* | +| `User-Agent` | `Polytomic/rel2021.05.25` | Illustrative only — the release suffix changes. **Never authenticate on it.** | +| `Content-Length` | e.g. `646` | Ordinary. | + +### The gzip ambiguity (the one inconsistent claim on the page) + +The docs' sample request shows an `Accept-Encoding: gzip` line, while the prose +says *"Polytomic delivers payloads as a gzipped response to minimize bandwidth +use. Your client likely supports decoding this automatically."* + +Those are in tension: `Accept-Encoding` on a **request** is a request header, so +it is not what would carry response-body compression. Rather than assert a header +we have not observed, take the operational consequence: + +- **The body may arrive gzip-compressed.** +- Most frameworks and reverse proxies **decompress it transparently** — so + `express.json()`, Next.js `request.json()` and FastAPI `await request.json()` + generally just work. +- **If you capture the raw body yourself**, you may need to inflate it. (Note that + with no signature there is no raw-body *requirement* here — unlike every + HMAC-signed provider, you are free to let the framework parse the JSON.) + +Treat this as **unsettled** until you capture a real delivery. Polytomic's sync +history view (Advanced settings → *Capture webhook requests and responses*, +default on) is the place to look. + +## Implementation + +There is **no Polytomic SDK webhook verifier** — no SDK helper exists for this, +because there is no signature scheme to wrap. Manual comparison is the +implementation for every framework and language. + +### Node.js + +```javascript +const crypto = require('crypto'); + +function verifyBearerToken(authorizationHeader, secret) { + if (!secret) return null; // unset => caller MUST fail closed (500) + if (typeof authorizationHeader !== 'string') return false; + + // Strip exactly ONE leading "Bearer " prefix. The scheme is case-insensitive + // per RFC 7235; the token after it is not. + const token = authorizationHeader.replace(/^Bearer /i, ''); + + const a = Buffer.from(token); + const b = Buffer.from(secret); + // Length-guard first: crypto.timingSafeEqual throws on unequal buffer lengths. + return a.length === b.length && crypto.timingSafeEqual(a, b); +} +``` + +### Python + +```python +import hmac + +def verify_bearer_token(authorization_header: str | None, secret: str | None) -> bool | None: + if not secret: + return None # unset => caller MUST fail closed (500) + if not authorization_header: + return False + + # Strip exactly ONE leading "Bearer " prefix, case-insensitively on the scheme. + token = authorization_header + if token[:7].lower() == "bearer ": + token = token[7:] + + # Compare BYTES, not str: hmac.compare_digest() raises TypeError on str + # values containing non-ASCII characters, and the header is attacker-supplied. + return hmac.compare_digest(token.encode("utf-8"), secret.encode("utf-8")) +``` + +`hmac.compare_digest` is used here purely as Python's constant-time byte +comparison. **No HMAC is computed** — there is no `hmac.new(...)` call anywhere. + +### Timestamp freshness (optional) + +```javascript +// Returns true when within tolerance. toleranceSeconds <= 0 disables the check. +function timestampIsFresh(header, toleranceSeconds = 300) { + if (toleranceSeconds <= 0) return true; + if (typeof header !== 'string') return false; + // RFC 3339 UTC, e.g. "2021-06-01T22:55:36Z". NEVER parseInt() this. + const sent = new Date(header); + if (Number.isNaN(sent.getTime())) return false; + return Math.abs(Date.now() - sent.getTime()) <= toleranceSeconds * 1000; +} +``` + +```python +from datetime import datetime, timezone + +def timestamp_is_fresh(header: str | None, tolerance_seconds: int = 300) -> bool: + if tolerance_seconds <= 0: + return True + if not header: + return False + try: + # RFC 3339 UTC, e.g. "2021-06-01T22:55:36Z". NEVER int() this. + # fromisoformat() only accepts a literal "Z" on Python 3.11+, so + # normalise it to "+00:00" for 3.9/3.10 compatibility. + sent = datetime.fromisoformat(header.strip().replace("Z", "+00:00")) + except ValueError: + return False + if sent.tzinfo is None: + sent = sent.replace(tzinfo=timezone.utc) + delta = abs((datetime.now(timezone.utc) - sent).total_seconds()) + return delta <= tolerance_seconds +``` + +## Fail Closed on a Missing Secret + +> **If `POLYTOMIC_WEBHOOK_SECRET` is unset, return 500 and log loudly. Never +> silently accept.** + +This matters more here than on a signed provider. On a no-signature provider the +bearer token *is* the entire security boundary — treating "unconfigured" as +"accept everything" leaves a fully open endpoint that looks secure. The examples +model this by returning `null` from the verifier when the secret is unset, which +the route turns into a 500. + +Status codes used by the examples: + +| Condition | Status | +|-----------|--------| +| Secret not configured | `500` (fail closed) | +| Missing or mismatched `Authorization` | `401` | +| Stale/unparseable `Polytomic-Signature-Timestamp` (when tolerance > 0) | `400` | +| Malformed JSON or malformed envelope | `400` | +| Unknown `event` value | `200` (ignored — the docs anticipate future types) | +| Accepted | `200` | + +Note the cost of each 4xx: *"Any 4xx or 5xx error will cause the sync to appear as +a failure."* Rejections are intentionally visible in Polytomic's sync history — +that is a feature, not a problem. But it is also why **unknown events must return +200**. + +## Do NOT Verify the Token as a JWT + +The documented sample `Authorization` value decodes to a real HS256 JWT with +claims: + +```json +{ + "aud": "webhook", + "jti": "00000000-0000-0000-0000-000000000000", + "iss": "https://app.polytomic-local.com:8443/" +} +``` + +(`iss` is the issuing Polytomic instance; the docs' example is a local dev host, +hence `polytomic-local`.) It is worth mentioning in your own docs so readers +aren't surprised the secret *looks* structured — but hedge it as **"observed in +the documented example"**, since the docs never describe the token's internal +format. + +**Treat the whole bearer string as an opaque secret and compare it +byte-for-byte**, because: + +- It is signed with a key **Polytomic does not give you**, so `jwt.verify()` + cannot succeed. +- It carries **no `exp`**, so there is no expiry to check. +- The docs call it **"a static value"** — it is a long-lived shared credential, + not a rotating assertion. + +A verifier that calls `jwt.verify` / `jwt.decode`, checks `exp`, or validates +`aud` / `iss` will either crash or add a false sense of security. Your own +workspace's real secret may not even be a JWT. + +## Common Gotchas + +- **Writing an HMAC verifier.** The most common failure mode. There is no + signature to verify — the header named `...-Signature-Timestamp` is a timestamp. +- **`parseInt` / `int()` on the timestamp.** It is RFC 3339, not epoch seconds. + `parseInt("2021-06-01T22:55:36Z")` returns `2021`, which looks like a valid + number and silently breaks every freshness check. +- **`datetime.fromisoformat` and the trailing `Z`.** Only accepted natively on + Python 3.11+. Replace `Z` with `+00:00` for 3.9/3.10. +- **`crypto.timingSafeEqual` throwing.** It raises on unequal buffer lengths, and + the attacker controls the incoming length. **Length-guard first.** +- **Case-sensitivity of the scheme.** Strip `Bearer ` case-insensitively (RFC + 7235), but compare the token case-**sensitively**. +- **Stripping more than one prefix.** Use a single anchored replacement, not a + loop or a global regex — a token could legitimately start with `Bearer`. +- **Header-name casing.** Node and FastAPI lower-case incoming header names; + read `authorization` and `polytomic-signature-timestamp` via your framework's + case-insensitive accessor rather than matching the docs' casing literally. +- **Assuming one record.** `object.records` is a batch (default 100, + configurable). Loop. +- **Typing `fields` as a fixed shape.** Its keys come from the user's sync + configuration. `email` / `last_login` are that customer's choices. +- **Assuming `metadata` is an object.** Its default is `null`; it may also be + absent. +- **Erroring on an unknown `event`.** Return 200 — the docs anticipate new types, + and a 4xx marks the sync failed. +- **Treating `records[].hash` as a credential.** It is a content digest for + deduplication, computed over data Polytomic is sending you. It authenticates + nothing. +- **Relying on the IP allowlist as authentication.** It is a firewall + convenience, it is documented for a different purpose (database/warehouse + connections), and it does not apply to self-hosted Polytomic. + +## Debugging Verification Failures + +**Everything 401s.** + +1. Confirm `POLYTOMIC_WEBHOOK_SECRET` exactly matches the connection Secret — + hover the secret key field in Polytomic to reveal it. Watch for a trailing + newline from `echo` into `.env`, or shell-mangled characters. +2. Check you are stripping exactly one `Bearer ` prefix and not comparing the + whole header against the bare secret. +3. Open the sync history view (*Capture webhook requests and responses*, default + on) and read the exact `Authorization` value Polytomic sent. + +**Everything 500s.** The secret is unset in that environment — that is the +fail-closed path working as designed. Check your deployment's env config. + +**Everything 400s with a timestamp error.** Either your server clock has drifted, +or something is parsing the RFC 3339 value as an integer. Set +`POLYTOMIC_TIMESTAMP_TOLERANCE_SECONDS=0` to isolate the freshness check, then +fix the parse. + +**Nothing arrives at all.** There is no handshake, so a wrong URL fails silently +until the sync runs. Check the sync actually ran, check the sync history, and if +you are behind a firewall see the IP notes in [setup.md](setup.md). + +**The first delivery was enormous.** That is the first-run backfill, not a bug. +See *Skip backfill on first sync* in [setup.md](setup.md). + +## Source + +All quoted text above is from +[Webhooks — Destination](https://docs.polytomic.com/docs/webhooks-connections) +(also available as +[markdown](https://docs.polytomic.com/docs/webhooks-connections.md)), which is +Polytomic's only webhook documentation page. From 5afc43be36a9f659495b683d69f11e005b2feea7 Mon Sep 17 00:00:00 2001 From: garethx Date: Thu, 1 Oct 2026 18:57:13 +0100 Subject: [PATCH 2/2] fix: quote the IP allowlist page verbatim The IP-allowlist caveat in SKILL.md and references/setup.md presented a paraphrase inside quotation marks. Replaced with the verbatim sentence from https://docs.polytomic.com/docs/whitelist-ips so the attribution matches the source. Co-Authored-By: Claude Opus 5 --- skills/polytomic-webhooks/SKILL.md | 7 ++++--- skills/polytomic-webhooks/references/setup.md | 5 +++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/skills/polytomic-webhooks/SKILL.md b/skills/polytomic-webhooks/SKILL.md index 28c02226..eea4a106 100644 --- a/skills/polytomic-webhooks/SKILL.md +++ b/skills/polytomic-webhooks/SKILL.md @@ -271,9 +271,10 @@ if your API sits behind a firewall. Those addresses are `54.190.82.25`, `44.232.40.21`, `35.155.106.54`, `54.200.67.134`, `44.224.213.129` and `54.149.95.139`. -Two honest caveats: that page frames the list as the IPs for *"integrating -Polytomic with databases, data warehouses, and cloud storage"* (the webhooks page -merely links to it for webhook traffic), and it **does not apply to self-hosted / +Two honest caveats: that page frames the list for a different purpose — +*"When connecting Polytomic to your databases, data warehouses, and cloud storage +buckets, you may need to whitelist our IP addresses"* (the webhooks page merely +links to it for webhook traffic) — and it **does not apply to self-hosted / on-premise Polytomic deployments** — stated on that page. Treat it as **a firewall convenience Polytomic points you at, not an authentication mechanism.** diff --git a/skills/polytomic-webhooks/references/setup.md b/skills/polytomic-webhooks/references/setup.md index 77ae5203..10f76adf 100644 --- a/skills/polytomic-webhooks/references/setup.md +++ b/skills/polytomic-webhooks/references/setup.md @@ -129,8 +129,9 @@ If your API sits behind a firewall, the webhooks page points you at Three honest caveats: -1. **That page is framed around a different purpose** — it describes the IPs for - *"integrating Polytomic with databases, data warehouses, and cloud storage"*. +1. **That page is framed around a different purpose.** Verbatim: *"When + connecting Polytomic to your databases, data warehouses, and cloud storage + buckets, you may need to whitelist our IP addresses."* The webhooks page merely links to it for webhook traffic. Present it as **the allowlist Polytomic points you at**, not as a documented webhook-egress range. 2. **It does not apply to self-hosted / on-premise Polytomic.** Verbatim from