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.
- In OnePageCRM, open Apps.
- Find Webhooks and activate it.
- 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)
- Name:
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": { "...": "..." },
"...": "..."
}
}
typeis the entity (contact),reasonis what happened (updated). The full matrix is on the Events page.secretkeyis 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.datanests the resource under its entity key — the contact’s id isdata.contact.id, notdata.id. It’s the same shape as the API v3GETresponse, sibling keys likenext_actionsincluded. 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), butdeletedevents carry a flatdata.id.data[type]?.id ?? data.idhandles both. - The dedupe key:
type:id:timestampmakes 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.