Nimply Developers
Examples

Webhook receiver

A minimal Express server that verifies Nimply webhook signatures.

Scopes: webhooks:manage (to register the endpoint).

This recipe builds the smallest correct receiver: raw-body capture, X-Nimply-Signature verification, fast 2xx acknowledgement, and deduplication. The full event catalogue and retry behavior live in the webhooks guide.

1. The server

import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";

const app = express();
const SECRET = process.env.NIMPLY_WEBHOOK_SECRET; // whsec_..., returned on webhook creation

function verify(rawBody, header, secret) {
  const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

const seen = new Set(); // use a persistent store in production

app.post("/hooks/nimply", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.get("X-Nimply-Signature");
  if (!signature || !verify(req.body, signature, SECRET)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(req.body);

  // Deliveries can repeat — dedupe by the envelope id
  if (seen.has(event.id)) return res.sendStatus(200);
  seen.add(event.id);

  // Acknowledge fast, process after
  res.sendStatus(200);

  switch (event.type) {
    case "post.published":
      console.log("live:", event.data.postId, event.data.postUrl);
      break;
    case "post.failed":
      console.error("failed:", event.data.postId, event.data.error);
      break;
    default:
      console.log("event:", event.type);
  }
});

app.listen(3000);

Two details are load-bearing:

  • express.raw, not express.json — the v1 value is the hex HMAC-SHA256 of "{t}.{rawBody}" using your secret. Parsing and re-serializing the JSON can change key order and break the signature, so the HMAC must be computed over the raw bytes.
  • Timestamp check — rejecting t older than ~5 minutes prevents replay of captured deliveries. Compare with timingSafeEqual, never ===.

2. Register the endpoint

curl -X POST https://api.nimply.io/v1/webhooks \
  -H "Authorization: Bearer $NIMPLY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Publishing monitor",
    "url": "https://example.com/hooks/nimply",
    "events": ["post.published", "post.failed"]
  }'

The response includes the signing secret (whsec_...) — store it as NIMPLY_WEBHOOK_SECRET. It is per-webhook, not per-workspace.

3. Test it

curl -X POST https://api.nimply.io/v1/webhooks/{webhookId}/test \
  -H "Authorization: Bearer $NIMPLY_API_KEY"

Your server should log a webhook.test event and return 200. If it logs "invalid signature" instead, the usual culprit is a JSON body parser mounted before the raw handler.

Gotchas

  • Respond 2xx within 15 seconds — do slow work after acknowledging, not before.
  • Failed deliveries are retried 5 times with exponential backoff; after 10 consecutive failures the endpoint is auto-disabled (isActive: false). Re-enable with PATCH /v1/webhooks/{id} once fixed.
  • Deliveries can arrive out of order and more than once — the X-Nimply-Delivery header (the event id) is stable across retries, so dedupe on it or on the envelope id.

On this page