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

## 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/polytomic-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/polytomic-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/polytomic-webhooks/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