SimplyParseDocs

Verify signatures

Check X-SimplyParse-Signature so your endpoint only accepts unaltered payloads from SimplyParse.

Every webhook request carries an X-SimplyParse-Signature header: a hex-encoded HMAC-SHA256 of the payload. Recompute it on your side and compare before you trust the body.

How the signature is computed

AlgorithmHMAC-SHA256, hex digest (64 lowercase characters)
KeyYour parser ID as a string, such as 1679091c-5a88-4faf-afb5-e6087eb1b2dc (from the dashboard URL /parsers/<id>)
MessageThe JSON payload in compact form: no spaces after , or :

The signature covers the payload as sent, after any data mapping has been applied.

The body on the wire is not the signed text

The request body is sent with a space after each , and :, but the signature is computed over the compact form. Hashing the raw body as-is will never match. Convert it to compact form first, as in the code below.

In JavaScript, don't use JSON.stringify(JSON.parse(body)). It rewrites numbers (48250.0 becomes 48250) and unescapes characters such as é, which changes the text. Remove the whitespace outside strings instead.

Verify in your code

Read the raw request body before any JSON middleware parses it.

import crypto from "node:crypto";

// SimplyParse signs the body in compact JSON form (no spaces after "," and ":"),
// but sends it with spaces. Strip whitespace outside strings to get the signed text.
// Don't use JSON.stringify(JSON.parse(body)): it rewrites numbers such as 48250.0.
function compactJson(raw) {
  let out = "";
  let inString = false;
  let escaped = false;
  for (const ch of raw) {
    if (inString) {
      out += ch;
      if (escaped) escaped = false;
      else if (ch === "\\") escaped = true;
      else if (ch === '"') inString = false;
    } else if (ch === '"') {
      inString = true;
      out += ch;
    } else if (ch !== " " && ch !== "\n" && ch !== "\r" && ch !== "\t") {
      out += ch;
    }
  }
  return out;
}

export function verifySignature(rawBody, signature, parserId) {
  if (typeof signature !== "string") return false;
  const expected = crypto
    .createHmac("sha256", parserId)
    .update(compactJson(rawBody), "utf8")
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Python, re-serializing with json.dumps(..., separators=(",", ":")) reproduces the signed text exactly, because it's the same serializer SimplyParse uses.

Then, in your handler:

import express from "express";
import { verifySignature } from "./verify-signature.js";

const app = express();

app.post(
  "/hooks/simplyparse",
  express.raw({ type: "application/json" }), // keep the raw bytes
  (req, res) => {
    const raw = req.body.toString("utf8");
    if (!verifySignature(raw, req.get("X-SimplyParse-Signature"), process.env.SIMPLYPARSE_PARSER_ID)) {
      return res.status(401).send("invalid signature");
    }
    const event = JSON.parse(raw);
    // ...store event.data and return quickly
    res.sendStatus(204);
  },
);

Several parsers, one endpoint

Each parser signs with its own ID. If one endpoint receives webhooks from several parsers, keep a list of your parser IDs and accept the request if the signature matches any of them. Alternatively, give each parser its own URL, such as /hooks/simplyparse/invoices, and look up the ID from the path.

Add a shared secret too

The parser ID is an identifier, not a secret. It appears in dashboard URLs and in export calls. So pair the signature with a secret header: add a header such as X-Webhook-Secret with a long random value to the webhook in the dashboard, and reject requests that don't carry it.

import crypto from "node:crypto";

// Call this first in your handler: if (!hasValidSecret(req)) return res.sendStatus(401);
export function hasValidSecret(req) {
  const received = Buffer.from(req.get("X-Webhook-Secret") ?? "");
  const expected = Buffer.from(process.env.SIMPLYPARSE_WEBHOOK_SECRET);
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

Together, the secret header proves the sender knows your secret, and the signature proves the body wasn't changed.

Troubleshooting

SymptomFix
Every signature failsYou're hashing the raw body. Convert it to compact form first.
Fails only for some payloads (Node)You used JSON.stringify(JSON.parse(...)). Use compactJson above.
Fails after JSON middleware runsThe framework already parsed the body. Read the raw bytes (express.raw, request.body(), request.get_data()).
Fails for one parser onlyThat parser's ID is different. Check you're using the ID of the parser that sent it.
Fails after adding a data mappingNothing to change: the signature covers the mapped payload you receive. Check the other causes above.

On this page