---
title: "Security"
description: "Verifying the webhook secret key with a constant-time compare, the HTTPS requirement, and an honest look at the threat model."
canonical_url: https://developer.onepagecrm.com/webhooks/security/
source: Markdown mirror of https://developer.onepagecrm.com/webhooks/security/
---

Anyone who discovers your webhook URL can POST to it. Verification
is how you tell a genuine OnePageCRM event from a forged one.

## The threat model, honestly

OnePageCRM webhooks do **not** carry a signature header. There is no
HMAC of the body and no timestamp signature. The shared secret you
configure is sent in the body as the `secretkey` field, on every
delivery.

That means:

- The `secretkey` field is the **only** authenticity signal. If it
  matches your stored secret, the request knew the secret; if not,
  reject it.
- The secret travels with every payload, so transport security does
  the heavy lifting. **Always use an HTTPS URL**.
- A matching secret proves knowledge of the secret, not integrity
  of the payload. For anything high-stakes, treat the event as a
  trigger and re-fetch the record from the
  [API](/api/reference/) before acting on it — and rotate the secret
  on any suspicion of exposure.

## Verify with a constant-time compare

Compare the received `secretkey` against your stored secret using a
constant-time comparison, not `==`. A plain equality check returns
early on the first differing byte, which leaks timing information an
attacker can use to recover the secret byte by byte.

### Node

```js
import crypto from "node:crypto";

const SECRET = process.env.OPCRM_WEBHOOK_SECRET;

function verifySecret(received) {
  const a = Buffer.from(received ?? "", "utf8");
  const b = Buffer.from(SECRET, "utf8");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

`timingSafeEqual` throws on unequal lengths, so check the length first.

### Python

```python
import hmac
import os

SECRET = os.environ["OPCRM_WEBHOOK_SECRET"]

def verify_secret(received: str | None) -> bool:
    return hmac.compare_digest(received or "", SECRET)
```

## Checklist

- [ ] Webhook URL uses HTTPS
- [ ] A secret key is configured on the webhook
  ([in the Apps page](/webhooks/manage/))
- [ ] `secretkey` verified with `crypto.timingSafeEqual` /
  `hmac.compare_digest`, never `==`
- [ ] Requests with a missing or wrong secret are rejected before
  any processing
- [ ] The secret lives in your environment or secret store, not in
  source control
- [ ] The URL path is unguessable (e.g. includes a random segment) —
  cheap defense in depth
- [ ] High-stakes actions re-fetch the record from the API instead
  of trusting the payload alone

Rotate the secret from the Webhooks app — see
[Manage webhooks](/webhooks/manage/). Update your receiver first so
it accepts both the old and new secret, then change the config.

## Common questions

**Does OnePageCRM sign webhook payloads?**
No. There is no HMAC signature header and no timestamp signature. The
shared secret travels in the body as the `secretkey` field on every
delivery — matching it against your stored secret is the only
authenticity check.

**How should I compare the secret?**
With a constant-time comparison — `crypto.timingSafeEqual` in Node,
`hmac.compare_digest` in Python — never `==`. A plain equality check
returns early on the first differing byte, which leaks timing
information an attacker can use to recover the secret.

**How do I rotate a webhook secret?**
Update your receiver first so it accepts both the old and new secret,
then change the config in the Webhooks app — see
[Manage webhooks](/webhooks/manage/).
