---
title: "Subscribe to webhook events"
description: "Activate webhooks, catch your first event with webhook.site, then build a small verified receiver in Node."
canonical_url: https://developer.onepagecrm.com/tutorials/webhooks/
source: Markdown mirror of https://developer.onepagecrm.com/tutorials/webhooks/
---

By the end of this tutorial you'll have caught a live OnePageCRM
webhook, read its payload, and built a small Node receiver that
verifies the secret key and handles duplicate events. No prior
webhook experience needed.

Webhooks push changes to you: register a URL, and OnePageCRM POSTs
to it shortly after a contact, deal, action, note, call,
meeting, or company changes. No polling loop, no "did anything
change?" requests.

## 1. Get a URL you can watch

You need somewhere for OnePageCRM to POST. Before writing any code,
use a request-capture service:

- [**webhook.site**](https://webhook.site) — open it and you get an
  instant unique URL with a live request inspector. We'll use this.
- [**ngrok**](https://ngrok.com) — for later, when you want to hit a
  real handler on your laptop.

Open [webhook.site](https://webhook.site) and copy **"Your unique
URL"**. Keep the tab open — requests appear there live.

## 2. Activate the Webhooks app

You need to be an **administrator** on your OnePageCRM account.

1. In OnePageCRM, open **Apps**.
2. Find **Webhooks** and activate it.
3. Configure the webhook:
   - **Name**: `Webhook test`
   - **Target URL**: your webhook.site URL
   - **Format**: `json`
   - **Secret key**: `tutorial-secret` (we'll verify it in step 5)

That's it. Every change in the account now POSTs to your URL.

## 3. Trigger an event

Open any contact in OnePageCRM and edit something — change the job
title, add a tag. Save.

Now watch the webhook.site tab. A POST request appears shortly
after the change — usually within seconds, longer when the queue is
busy.

> Nothing arriving? Check the contact isn't **private** — webhooks
> are never sent for private contacts. See
> [privacy exclusions](/webhooks/events/#privacy-exclusions).

## 4. Read the payload

The request body has exactly five top-level keys:

```json
{
  "type": "contact",
  "reason": "updated",
  "timestamp": 1781426730,
  "secretkey": "tutorial-secret",
  "data": {
    "contact": {
      "id": "5aba31ea9007ba0f570c92d4",
      "first_name": "Jane",
      "last_name": "Doe",
      "job_title": "Operations Lead",
      "...": "..."
    },
    "next_actions": ["..."],
    "next_action": { "...": "..." },
    "...": "..."
  }
}
```

- `type` is the entity (`contact`), `reason` is what happened
  (`updated`). The full matrix is on the
  [Events](/webhooks/events/) page.
- `secretkey` is the secret you configured in step 2, echoed back
  in every payload. There is no signature header — this field is
  how you authenticate the request.
- `data` nests the resource under its entity key — the contact's id
  is `data.contact.id`, not `data.id`. It's the same shape as the
  [API v3](/api/reference/) `GET` response, sibling keys like
  `next_actions` included. Details on the
  [Payloads](/webhooks/payloads/) page.

Try one more trigger: delete a test contact. The `deleted` event's
`data` contains only `{"id": "..."}` — flat, no entity key, always.

## 5. Build a verified receiver

Time to replace webhook.site with real code. This receiver does the
three things every production receiver must do: verify the secret
with a constant-time compare, deduplicate, and respond fast.

```bash
mkdir opcrm-hooks && cd opcrm-hooks
npm init -y && npm install express
```

Create `server.mjs` (the `.mjs` extension lets Node run the
`import` syntax without any config):

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

const SECRET = process.env.OPCRM_WEBHOOK_SECRET ?? "tutorial-secret";
const seen = new Set(); // use a real store in production

const app = express();
app.use(express.json());

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

app.post("/hooks/onepagecrm", (req, res) => {
  const { type, reason, timestamp, secretkey, data } = req.body;

  // 1. Authenticate — constant-time, never ==
  if (!verifySecret(secretkey)) {
    return res.status(401).end();
  }

  // 2. Extract the entity id — nested under the entity key,
  //    except deleted events, where data is just { id }
  const entity = data[type];
  const id = entity?.id ?? data.id;

  // 3. Deduplicate — replays of the same delivery are skipped
  const key = `${type}:${id}:${timestamp}`;
  if (seen.has(key)) {
    return res.status(200).end();
  }
  seen.add(key);

  // 4. Respond promptly, process after
  res.status(200).end();

  console.log(`[${new Date().toISOString()}] ${type} ${reason} ${id}`);
});

app.listen(3000, () => console.log("Listening on :3000"));
```

Run it, and expose it with ngrok:

```bash
node server.mjs
ngrok http 3000
```

Update your webhook's target URL in the Apps page to
`https://YOUR-NGROK-HOST/hooks/onepagecrm`. Edit a contact again —
shortly after, your terminal logs the event.

Why each piece matters:

- **`timingSafeEqual`**, not `==`: a plain comparison leaks timing
  information. See [Security](/webhooks/security/).
- **The id extraction**: payloads nest the resource under its
  entity key (`data.contact.id`), but `deleted` events carry a flat
  `data.id`. `data[type]?.id ?? data.id` handles both.
- **The dedupe key**: `type:id:timestamp` makes processing the same
  delivery twice harmless. It deliberately does *not* collapse
  distinct events — three quick edits are three real changes, each
  with its own timestamp.
- **Respond, then process**: slow responses count as failed, and a
  bulk update can send one event per affected record. Queue first.

## 6. Test the failure modes

Three experiments worth running before you ship anything real.

**Kill the server.** Stop `node server.mjs`, edit a contact, wait,
then restart. The event never arrives — and never will. Delivery is
**at-most-once**: failed deliveries are not retried, and nothing
alerts you when one is missed. This is why real syncs pair webhooks
with a periodic reconciliation poll — see
[Delivery](/webhooks/delivery/).

**Send a wrong secret.** Forge a request yourself:

```bash
curl -X POST http://localhost:3000/hooks/onepagecrm \
  -H "Content-Type: application/json" \
  -d '{"type":"contact","reason":"updated","timestamp":1,"secretkey":"wrong","data":{"contact":{"id":"x"}}}'
```

You should get a `401` and no log line. If you get anything else,
fix the verification before going further.

**Replay a delivery.** Every genuine delivery has its own timestamp,
so the way to test the dedupe is to send the *same* request twice:

```bash
BODY='{"type":"contact","reason":"updated","timestamp":1781426730,"secretkey":"tutorial-secret","data":{"contact":{"id":"5aba31ea9007ba0f570c92d4"}}}'
curl -X POST http://localhost:3000/hooks/onepagecrm \
  -H "Content-Type: application/json" -d "$BODY"
curl -X POST http://localhost:3000/hooks/onepagecrm \
  -H "Content-Type: application/json" -d "$BODY"
```

The first request logs the event; the second returns `200` with no
log line. That's the idempotency working. Note what it does *not*
do: three rapid edits to a contact produce three distinct events
with distinct timestamps — those are real changes and your receiver
processes all of them.

## Where to go next

- [Webhooks overview](/webhooks/overview/) — the section index.
- [Events](/webhooks/events/) — every entity × reason combination,
  including dynamic `changed_to_<status>` deal events.
- [Payloads](/webhooks/payloads/) — the payload envelope and full
  sample payloads.
- [Delivery](/webhooks/delivery/) — the semantics your architecture
  must respect.
- [Security](/webhooks/security/) — the threat model and a Python
  version of the verification code.
- [Manage webhooks](/webhooks/manage/) — set up configs in the Apps page.
