Skip to content
EN

Build a local receiver for Re:Earth CMS webhooks

Last updated

Whenever an item or an asset changes in Re:Earth CMS, a Webhook can send that change to a server of your own as an HTTP POST. In this tutorial, you’ll run a small TypeScript server on your own machine that receives those requests, checks that each one really came from your CMS, and prints a readable summary of the change. Printing to the console is the starting point: at the end, you’ll design how your receiver should notify people.

You edit an item in the CMS, and a few seconds later your terminal shows what changed, who changed it, and when:

[item.update] YYYY-MM-DD hh:mm:ss UTC
model: webhook-test
item: 01abc…
changed: title: "draft" → "final"
by: user 01xyz…
event: 01def…
received: YYYY-MM-DD hh:mm:ss UTC

The CMS can’t reach localhost, so a tunnel gives your machine a temporary public address and forwards each request to the receiver:

Re:Earth CMS ──POST + Reearth-Signature──► cloudflared tunnel ──► localhost:3000/webhook

Architecture diagram: Re CMS sends a POST request with a Reearth-Signature header through the cloudflared tunnel to the receiver at localhost:3000/webhook on your machine; the receiver's response goes back through the tunnel to the CMS, and the receiver passes each event to notify(), which prints it to the console.

Compare your project with the finished source code.

Before you start, make sure you have the following:

  • Node.js 20.6 or later. The receiver loads its settings with Node’s built-in --env-file, which needs this version.
  • Git
  • A Re:Earth CMS workspace where you have the Owner or Maintainer role. Connecting an Integration to a workspace needs one of these roles. Your personal workspace works, because you’re its Owner.
  • Basic familiarity with TypeScript

A webhook belongs to an Integration, so you’ll create an Integration first, connect it to a workspace, and then create a small model to make changes in.

Create the Integration in your personal account, then connect it to the workspace you’ll use:

  1. In your personal account, open the My Integrations tab.
  2. Click Create new integration.
  3. Enter CMS Webhook Tutorial as the name.
  4. Click Create.
  5. In the workspace you’ll use, open the Integrations tab.
  6. Click Connect Integration.
  7. Select CMS Webhook Tutorial.
  8. Click Connect.

The Integration now appears on the workspace’s Integrations screen. Any role works for this tutorial: the CMS sends webhook events to every connected Integration, whatever its role. For the full steps and the roles, see Creating an Integration.

You’ll add the webhook itself in Step 4, once the receiver has an address the CMS can reach.

In the same workspace, create a project named webhook-tutorial-test. In it, create a model named webhook-test and add a Text field with the key title.

This model is where you’ll make the changes that produce events in Step 5.

Step 2 — Run the receiver on your machine

Section titled “Step 2 — Run the receiver on your machine”

Clone the finished receiver and install its dependencies:

Terminal window
git clone https://github.com/eukarya-biz/cms-webhook-tutorial.git
cd cms-webhook-tutorial
npm install
cp .env.example .env

On Windows (Command Prompt), use copy .env.example .env instead of cp .env.example .env.

The receiver needs a secret, a random value that both the CMS and your receiver know. Generate one:

Terminal window
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Open .env and set CMS_WEBHOOK_SECRET to the value it printed. Leave PORT at 3000 and CMS_PROJECT_ID empty. Keep the secret to hand: you’ll enter the same value in the CMS in Step 4.

Before involving the CMS, check that the signature check works. The repository includes tests for it:

Terminal window
npm test

All 8 tests pass. They confirm that a correctly signed request is accepted, and that a changed body, a different secret, a timestamp more than 5 minutes off, and a malformed header are all rejected.

Now start the receiver:

Terminal window
npm run dev

The terminal shows CMS Webhook Receiver listening on http://localhost:3000. Leave it running, open a second terminal in the same folder, and send a test event. The repository’s test script signs a sample event with your secret, the same way the CMS does:

Terminal window
npm run send-test

The second terminal shows Sent item.create (test-…) → 200, and the first one prints your first summary:

[item.create] YYYY-MM-DD hh:mm:ss UTC
model: webhook-test
item: test-item
field: title = "hello from the test script"
by: user test-user
event: test-…
received: YYYY-MM-DD hh:mm:ss UTC

Your receiver accepts a correctly signed request. Next, you’ll see what it does with one before it prints anything.

Step 3 — See how the receiver checks a request

Section titled “Step 3 — See how the receiver checks a request”

Every request arrives at POST /webhook in src/server.ts. Here’s how the receiver reads it:

src/server.ts
app.post(
"/webhook",
express.raw({ type: () => true, limit: "1mb" }),
(req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const result = verifySignature(
rawBody,
req.get("Reearth-Signature"),
secret,
);
if (!result.ok) {
console.warn(`Rejected webhook: ${result.reason}`);
res.sendStatus(401);
return;
}
// Then: parse the JSON, reply 200, and handle the event.
},
);

Two details here matter more than they look.

The body is read as raw bytes. The CMS signs the exact bytes it sends, so the receiver has to check the signature against those bytes before parsing anything. The CMS also sends no Content-Type header, and that’s why the parser is given type: () => true. With the more common type: "*/*", Express reads a body only when a Content-Type header is present. Every request from the CMS would then arrive with an empty body, and every signature check would fail.

The signature is in the Reearth-Signature header. It looks like v1,t=<unix_time>,<signature>: a version, the time the CMS sent the request in seconds since 1970, and the signature itself. src/verify.ts recalculates the signature with your secret and compares the two:

src/verify.ts
// Earlier in verifySignature: the header is split into `timestamp` and
// `signature`, and a timestamp more than 5 minutes off is rejected.
const expected = createHmac("sha256", secret)
.update(`v1:${timestamp}:`)
.update(rawBody)
.digest("hex");
// timingSafeEqual throws if the lengths differ, so check that first.
const received = Buffer.from(signature);
const computed = Buffer.from(expected);
if (
received.length !== computed.length ||
!timingSafeEqual(received, computed)
) {
return { ok: false, reason: "signature mismatch" };
}

The signature is an HMAC-SHA256 of v1:, the timestamp, :, and then the body, keyed with your secret. Only someone who has the secret can produce a matching one, so a match tells you the request came from your CMS and wasn’t changed on the way. Before this comparison, verifySignature also rejects a timestamp more than 5 minutes away from the current time, so a request someone recorded can’t be replayed later.

Once the request is verified, the receiver replies 200 straight away and only then handles the event. The CMS waits at most 30 seconds for a reply and treats any status above 300 as a failed delivery, so slow work never goes before the reply.

The handling itself, in handleEvent(), skips an event it has already seen and event types it doesn’t handle, then passes the event to notify() in src/notify.ts. That’s the function that printed your summary, and it’s the one you’ll come back to in Step 6.

Step 4 — Connect the CMS through a tunnel

Section titled “Step 4 — Connect the CMS through a tunnel”

The CMS runs on the internet and can’t reach localhost. A Cloudflare quick tunnel gives your machine a temporary public address and forwards every request to it unchanged, which the signature check depends on. It’s free and needs no Cloudflare account.

Install cloudflared for your operating system. Then, with the receiver still running, start a tunnel in a new terminal:

Terminal window
cloudflared tunnel --url http://localhost:3000

After a few seconds, it prints a box containing an address that ends in trycloudflare.com. Copy that address, and leave this terminal running: closing it ends the tunnel, and the address changes every time cloudflared starts.

Check that requests from the internet reach your receiver, and that unsigned ones are turned away. Run this, replacing <tunnel_url> with the whole address you copied:

Terminal window
curl -i -X POST <tunnel_url>/webhook --data '{"hello":"world"}'

The response is 401 Unauthorized, and the receiver’s terminal shows Rejected webhook: missing Reearth-Signature header.

Now give the CMS that address. In My Integrations, open the CMS Webhook Tutorial Integration, open its Webhook tab, and click New Webhook.

The Webhook tab of an Integration's detail page with no webhooks yet, and the Webhook tab and the New Webhook button highlighted.

Fill in the form:

  • Name: local-receiver
  • URL: your tunnel address followed by /webhook, that is, <tunnel_url>/webhook
  • Secret: the same value as CMS_WEBHOOK_SECRET in your .env
  • Trigger Event: every Item and Asset event
The New Webhook form of the CMS Webhook Tutorial Integration, filled in with the name local-receiver, a trycloudflare.com URL ending in /webhook, the secret your-secret-here, and every Item and Asset trigger event checked.

Click Save, and check that the new webhook’s switch is set to ON.

The Webhook tab of the CMS Webhook Tutorial Integration, showing one webhook named local-receiver with its switch set to ON and its URL, a trycloudflare.com address ending in /webhook.

Keep the receiver’s terminal where you can see it. In the webhook-tutorial-test project, make these changes one at a time, and watch the terminal after each one:

  1. In the webhook-test model, create an item with the title draft. An [item.create] summary appears, with a field: line showing title = "draft".
  2. Change the title to final and save. An [item.update] summary appears, and its changed: line shows both values: title: "draft" → "final".
  3. Publish the item. An [item.publish] summary appears.
  4. Unpublish the item. An [item.unpublish] summary appears.
  5. Upload a small image to the project’s assets. An [asset.create] summary appears, with the file name and its size.
The receiver's terminal showing three summaries in order: item.create with the field line title = "draft", item.update with the changed line title: "draft" → "final", and item.publish. Each summary lists the model, item, user, event ID and received time, and the user IDs are hidden.

Each summary appears within a couple of seconds of the change. Compare the first line’s time with received:: the first is when the change happened in the CMS, and the second is when your receiver got it.

You’ll also notice a few things about which events arrive:

  • Every save is a separate event. Save an item twice and two item.update summaries arrive.
  • Deleting an item or an asset in the CMS doesn’t send an event. The CMS sends item.delete when a whole model is deleted, once for each item in it, and asset.delete when an asset is deleted through the Integration API.
  • A delivery that fails is sent again later. If your receiver was stopped or rejected a request, the same event can arrive a while afterwards, after newer ones. The receiver skips an event ID it has already handled and doesn’t rely on the order.

When you finish working, switch the webhook to OFF. Once the tunnel stops, its address no longer works, and every change in the workspace would become a failed delivery that the CMS may send again later. Next time, start the tunnel, enter its new address in the webhook’s settings, and switch it back on.

Printing to the console proves the whole path works. A real receiver would tell someone. This step doesn’t prescribe a design: you decide what your receiver should do, and how you’ll know it’s working.

The place to change is notify() in src/notify.ts. It’s called once for every verified event that passed the filters, and it can be async, so it can wait for another service to respond:

src/notify.ts
export async function notify(event: WebhookEvent): Promise<void> {
console.log(formatEvent(event));
}

Before you write code, work through these questions:

  • Who needs to know, and about which events? A reviewer might care only about item.publish in one model, and an editor about item.update. event.type, event.data.model.key, and the item’s fields are all in the event.
  • When should they hear about it? Straight away, or collected into one message every hour? A burst of saves on one item can produce a burst of events.
  • What happens if sending fails? The receiver has already replied 200, so the CMS won’t send the event again. Decide whether to retry, to log it, or to accept that it’s lost.
  • Where do the credentials go? A Slack webhook URL or an email password is a secret too. Keep it in .env beside CMS_WEBHOOK_SECRET, never in the code.
  • How will you test it without the CMS? npm run send-test accepts --type, --id, and --project, so you can send the events your design cares about.

These official guides are good starting points for two common destinations:

Decide what “done” means for your design before you start, in terms you can check. For example: “publishing an item in the webhook-test model posts one Slack message naming the item, and a failed post is written to the log”. Then check your receiver against it.

The receiver shows Rejected webhook: signature mismatch

The webhook’s Secret and CMS_WEBHOOK_SECRET in .env don’t match exactly. Check both for extra spaces or missing characters. If you edited .env while the receiver was running, stop it and run npm run dev again: the receiver reads .env only when it starts.

Nothing appears when you change an item

Check that the webhook’s switch is ON, that its URL ends in /webhook, that the events you’re triggering are selected, and that the Integration is connected to the workspace you’re working in.

The tunnel’s terminal shows Unable to reach the origin service

The tunnel is running, but the receiver isn’t. Start it again with npm run dev.

The receiver shows Ignored … from another project

CMS_PROJECT_ID in .env is set to a different project. Leave it empty to accept events from every project in the workspace.

  • An unsigned request to your tunnel address is rejected with 401.
  • Creating, editing, publishing, and unpublishing an item in the CMS each prints a summary in your terminal.
  • You can explain why the receiver reads the body as raw bytes and replies before handling the event.
  • You’ve decided what your notification should do, and what “done” means for it.