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
| Algorithm | HMAC-SHA256, hex digest (64 lowercase characters) |
| Key | Your parser ID as a string, such as 1679091c-5a88-4faf-afb5-e6087eb1b2dc (from the dashboard URL /parsers/<id>) |
| Message | The 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
| Symptom | Fix |
|---|---|
| Every signature fails | You'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 runs | The framework already parsed the body. Read the raw bytes (express.raw, request.body(), request.get_data()). |
| Fails for one parser only | That parser's ID is different. Check you're using the ID of the parser that sent it. |
| Fails after adding a data mapping | Nothing to change: the signature covers the mapped payload you receive. Check the other causes above. |