# Guide to Polytomic Webhooks: Features and Best Practices

Polytomic webhooks deliver the rows a sync has changed. You model data in a warehouse or database, point a sync at a Webhook destination, and Polytomic POSTs batches of new and updated records to your endpoint on the sync's schedule. If you are using [Polytomic](https://www.polytomic.com) to push data into a service that has no native connector, the Webhook destination is how those records reach your API.

This guide covers how Polytomic webhooks work, how to authenticate them when there is no signature to verify, the shape of the `sync.records` batch, how a failed response affects the sync, and the best practices for production.

## What are Polytomic webhooks?

Polytomic is a data movement platform: Model Syncs and Bulk Syncs carry data between warehouses, databases and SaaS applications. Its webhook surface is a destination. You add a Webhook connection with the URL to deliver to, create a Model Sync that targets it, and select the fields each record should include.

That makes Polytomic different from an event-driven provider. There are no event subscriptions and no per-event toggles. What arrives is the output of a sync run, as one or more batches of records that changed since the last payload.

The authentication is different too. Polytomic does not sign its payloads. Each request carries the connection's Secret as a bearer token in the `Authorization` header, and comparing that token is the whole check.

## Polytomic webhook features

| Feature | Details |
| --- | --- |
| Configuration | A Webhook connection under **Connections**, used as the destination of a Model Sync |
| Authentication | `Authorization: Bearer <secret>`, a static token matching the connection Secret |
| Signature | None. There is no HMAC, no digest and no signing secret |
| Timestamp header | `Polytomic-Signature-Timestamp`, an RFC 3339 UTC timestamp such as `2021-06-01T22:55:36Z`. It is not signed |
| Event types | One: `sync.records` |
| Payload | A batch in `object.records`, 100 records by default and configurable per sync |
| Content type | Always `application/json`. The body may arrive gzip-compressed |
| Response contract | Return `200 OK`. Any 4xx or 5xx marks the sync as failed |
| Retries | No retry policy is documented |
| Delivery log | Requests and responses are captured in the sync history view by default |
| Source IPs | Six published addresses, which do not apply to self-hosted Polytomic |

There is no verification handshake and no documented test event. Polytomic does not call your URL when you create the connection, so the first request you see is a real sync run.

## Common events

Polytomic documents exactly one event type.

| Event | Fires when | Payload |
| --- | --- | --- |
| `sync.records` | A Model Sync run has records to deliver | A batch of records in `object.records` |

The documentation describes the `event` field as a way to "distinguish new and future hooks" and tells you to process only the events you know about. Route on `event`, handle `sync.records`, and return 200 for anything else. There is no `sync.started`, `sync.completed` or `sync.failed` event.

The envelope looks like this:

```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 | Meaning |
| --- | --- |
| `object.id` | The UUID of the sync, matching the id in the URL of its configuration page. It does not identify the delivery |
| `object.name` | The sync's name, useful when several syncs post to one endpoint |
| `object.records` | The records changed since the last payload |
| `object.records[].hash` | A hash Polytomic computes over the record's field keys and values, intended for deduplication |
| `object.records[].fields` | The fields you selected in the sync. The keys are yours, so there is no fixed schema |
| `object.metadata` | Key-value pairs set in the sync's advanced settings. It can be an object, `null`, or missing |

## Setting up Polytomic webhooks

1. In Polytomic, go to **Connections**, click **Add Connection**, and choose **Webhook**.
2. Enter the URL Polytomic should deliver payloads to.
3. Polytomic generates a secret key for the connection. Hover over the secret key field to reveal it, and store it where your handler can read it.
4. Create a Model Sync that targets the connection and select the fields to deliver.

A connection on its own sends nothing. Deliveries start when a sync that targets it runs.

If the connection points at a Hookdeck source, enter the full header value in the source's **Authorization Header Value** field: `Bearer ` followed by the Secret, not the bare Secret. Hookdeck compares the whole header and does not strip the prefix.

The sync's **Advanced settings** change what your endpoint receives:

| Setting | Default | Effect |
| --- | --- | --- |
| Webhook batch size | 100 | How many records each POST carries |
| Metadata | `null` | Hardcoded keys and values echoed into `object.metadata` |
| Always do a full sync | Off | Syncs are differential by default. Turning this on sends every record on every run |
| Skip backfill on first sync | Off | The first run sends everything in the source unless this is on |
| Capture webhook requests and responses | On | Logs each request and response in the sync history view |

With no test event, the quickest way to exercise a handler is to 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"}}],"metadata":{}}}'

```

## Securing Polytomic webhooks

Polytomic's documentation is direct about the security model. The `Authorization` header "should be a 'Bearer' token matching the same value that was provided as the 'Secret' during connection setup", and "for now, this is the only request authorization and is a static value". No part of the request is a digest of the body, so the check is a comparison between the token you received and the Secret you stored.

```javascript
const crypto = require('crypto');

// Returns null when the secret is unset so the caller can fail closed with a 500.
function verifyBearerToken(authorizationHeader, secret) {
  if (!secret) return null;
  if (typeof authorizationHeader !== 'string') return false;

  // Strip one leading "Bearer " prefix. The scheme is case-insensitive,
  // the token after it is not.
  const token = authorizationHeader.replace(/^Bearer /i, '');

  // Compare as opaque bytes. The token is not a JWT you can validate.
  const a = Buffer.from(token);
  const b = Buffer.from(secret);
  // Length first: timingSafeEqual throws on unequal lengths.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

```

Return 401 on a mismatch and 500 when the secret is not configured. A handler that skips the check when its secret is missing is accepting requests from anyone who finds the URL.

The `Polytomic-Signature-Timestamp` header supports an optional freshness check. Polytomic suggests rejecting requests "more than a few minutes old". The value is an RFC 3339 timestamp, so parse it as a date:

```python
from datetime import datetime, timezone

def is_fresh(timestamp_header: str | None, tolerance_seconds: int = 300) -> bool:
    """Defence in depth only. The timestamp is not signed, so this says
    nothing about who sent the request."""
    if not timestamp_header:
        return False
    try:
        # "2021-06-01T22:55:36Z" is RFC 3339, not epoch seconds.
        sent_at = datetime.fromisoformat(timestamp_header.replace("Z", "+00:00"))
    except ValueError:
        return False
    if sent_at.tzinfo is None:
        sent_at = sent_at.replace(tzinfo=timezone.utc)
    age = abs((datetime.now(timezone.utc) - sent_at).total_seconds())
    return age <= tolerance_seconds

```

A static token in a header only stays secret over TLS, so serve the endpoint over HTTPS. If your API sits behind a firewall, Polytomic's webhook documentation links to its [IP allowlist page](https://docs.polytomic.com/docs/whitelist-ips). That page is written for database and warehouse connections and states that the addresses do not apply if you host Polytomic yourself. Use it to open the firewall, and keep the token check.

> **Make Polytomic webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) returns 200 to every Polytomic request so the sync is not marked failed, checks the bearer token before delivering, and durably queues every batch with retries and replay.

## Polytomic webhook limitations and pain points

### There is no signature to verify

**The Problem:** Nothing in a Polytomic request proves the body is the one Polytomic sent. Anyone who obtains the Secret can send your endpoint any payload they like.

**Why It Happens:** The only authentication is a static bearer token, and it is the same on every request. A token identifies the caller. It does not cover the body, the timestamp, or anything else in the request.

**Workarounds:**

* Compare the token in constant time, and fail closed when the secret is not configured.
* Keep the token out of logs. The request capture in Polytomic's sync history and your own access logs are the likely places for it to leak.
* When you rotate the Secret, accept the old and new values for a short overlap. Polytomic documents no rotation flow, and a 401 during the changeover marks the sync as failed.
* Validate the records before writing them, because the token cannot tell you they are unmodified.

**How Hookdeck Can Help:** Hookdeck's Polytomic source checks the `Authorization` header on every request. Hookdeck returns 200 to Polytomic either way, so a bad token never fails the sync, and a request with the wrong token is recorded in Hookdeck but not delivered to your destination. What it does forward is signed with a Hookdeck signature, so your service can verify the integrity of what it receives.

### `Polytomic-Signature-Timestamp` is not a signature

**The Problem:** The header name leads developers to write an HMAC verifier over the timestamp and body. There is nothing to compare its output against, so the verifier either rejects every request or gets switched off.

**Why It Happens:** The header carries a timestamp and nothing else. Polytomic's documentation says it may be used for signing in the future. The value is RFC 3339, so code that calls `parseInt` on it gets `2021` and fails every freshness check.

**Workarounds:**

* Do not compute a digest anywhere in the authentication path.
* Parse the header with `new Date(value)` or `datetime.fromisoformat`, and use it only for an optional staleness check.
* Do not treat the bearer token as a JWT either. The documented sample decodes as one, but Polytomic does not share its signing key and it has no expiry.

**How Hookdeck Can Help:** Hookdeck's request view shows the exact headers Polytomic sent, so you can confirm what is and is not there before writing verification code.

### Any error response fails the whole sync

**The Problem:** One slow database write or one malformed record turns a sync run red in Polytomic, and the batch is not delivered again on any schedule you can rely on.

**Why It Happens:** Polytomic's contract is that your API returns `200 OK`, and "any 4xx or 5xx error will cause the sync to appear as a failure". No retry policy is documented. Recovery is manual: fix the endpoint, then re-run the sync or wait for the next scheduled run.

**Workarounds:**

* Authenticate, enqueue the batch, and return 200. Process the records afterwards.
* Return 200 for unknown `event` values and for records you choose to skip. Handle per-record failures in your own dead-letter queue.
* Use the request and response log in the sync history to see what Polytomic sent and what your endpoint returned.

**How Hookdeck Can Help:** Hookdeck returns 200 to every Polytomic request and stores the batch, then retries delivery to your endpoint automatically. Requests with the wrong token are recorded and not delivered. Failures show up in Hookdeck, where they can be retried or replayed in bulk without re-running the sync.

### The first run sends everything

**The Problem:** The first delivery after you create a sync floods your endpoint with the entire source table.

**Why It Happens:** Model Syncs are differential, but the first run has nothing to diff against and sends every row. "Skip backfill on first sync" is off by default. With "Always do a full sync" on, every run behaves this way.

**Workarounds:**

* Turn on "Skip backfill on first sync" if you only want changes from now on.
* Size the batch with "Webhook batch size" so one POST stays inside your request body and timeout limits.
* Acknowledge quickly and queue the batch, so a large backfill becomes queue depth and your endpoint does not time out.

**How Hookdeck Can Help:** Hookdeck absorbs the burst and delivers to your destination at a rate limit you set, so a backfill of any size arrives at a pace your service can handle.

### `fields` has no fixed schema

**The Problem:** A handler typed against the documented `email` and `last_login` fields breaks when someone edits the sync.

**Why It Happens:** The keys inside `fields` are whichever fields were selected in the sync configuration. The documented example shows one customer's selection. Changing the sync changes the payload, and your handler gets no warning.

**Workarounds:**

* Type `fields` as an open map and validate only the keys you depend on.
* Treat any key as possibly absent and any value as possibly `null`.
* Use `object.id` or `object.name` to tell syncs apart when more than one posts to the same endpoint.

**How Hookdeck Can Help:** A Hookdeck transformation can normalise field names before delivery, and filters can route batches by `object.name`, which keeps per-sync differences out of your handler.

## Best practices

### Compare the token in constant time and fail closed

Use `crypto.timingSafeEqual` or `hmac.compare_digest` on bytes, with a length check first in Node. Return 401 for a mismatch and 500 when the secret is unset. Keep `createHmac` and `hashlib` out of the authentication path.

### Loop over the batch

`object.records` is an array on every delivery. Write the handler for hundreds of records per request, because the batch size is a setting someone can raise.

### Acknowledge with 200 and process asynchronously

The cost of a slow handler is a failed sync run. Store the batch, return 200, and process the records from a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Deduplicate on the sync id and record hash

A re-run sync or a full sync delivers records you have already processed. Use `object.id` together with `records[].hash` as the idempotency key. Polytomic does not document the hash's algorithm or length, so store it as an opaque string and do not try to recompute it. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Handle `metadata` in all three forms

`object.metadata` is an object when the sync defines metadata, and otherwise may be `null` or missing. Read it defensively.

### Expect compressed bodies

Polytomic's documentation says payloads are gzipped to save bandwidth. Most frameworks and reverse proxies decompress the body before your code sees it. If you read the raw stream yourself, be ready to inflate it.

### Return 200 for events you do not recognise

Polytomic expects to add event types. An error response to an unknown event would mark a customer's sync as failed for a payload you could have ignored.

## Conclusion

Polytomic's Webhook destination sends batches of synced records, authenticated by a static bearer token with no signature behind it. The `Polytomic-Signature-Timestamp` header is a timestamp, the single event type is `sync.records`, the field names belong to your sync, and any error response marks the run as failed with no documented retry. Compare the token in constant time, return 200 quickly, loop over every record, and deduplicate on the sync id and record hash.

[Hookdeck Event Gateway](https://hookdeck.com) acknowledges each batch immediately, checks the Polytomic bearer token before delivering, and durably queues it with retries and replay, so a problem in your service does not fail the sync or lose records.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Polytomic webhooks reliably in minutes.