Tutorials menu

Tutorial · Webhooks

Subscribe to webhook events

Activate webhooks, catch your first event with webhook.site, then build a small verified receiver in Node.

Last updated Jul 14, 2026

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 — open it and you get an instant unique URL with a live request inspector. We’ll use this.
  • ngrok — for later, when you want to hit a real handler on your laptop.

Open 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.

4. Read the payload

The request body has exactly five top-level keys:

{
  "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 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 GET response, sibling keys like next_actions included. Details on the 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.

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):

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:

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.
  • 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.

Send a wrong secret. Forge a request yourself:

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:

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 — the section index.
  • Events — every entity × reason combination, including dynamic changed_to_<status> deal events.
  • Payloads — the payload envelope and full sample payloads.
  • Delivery — the semantics your architecture must respect.
  • Security — the threat model and a Python version of the verification code.
  • Manage webhooks — set up configs in the Apps page.