Skip to content
ParsedMail

Webhooks

A ParsedMail webhook is an HTTPS POST to your endpoint each time an email is parsed. The JSON body holds the email object with typed data and confidence scores, signed with HMAC-SHA256 in the ParsedMail-Signature header. Answer with any 2xx status; anything else is retried five times over about 14 hours.

Add an endpoint

Webhooks are part of every paid plan. In the dashboard open Webhooks, enter the URL and optionally choose one inbox; without an inbox the endpoint receives emails from every inbox in the workspace. A workspace can have up to 20 endpoints, and each one can be switched off without deleting it. Deliveries still waiting for an endpoint that you disable or delete get status canceled: they are not retried and trigger no failure alert.

The URL must use https on port 443 or 8443, must not contain a username or password, and its host must resolve to public IP addresses only. The check runs when you add the endpoint and again before every attempt. Each endpoint gets its own signing secret that starts with whsec_.

An email is sent to your endpoints when it reaches status parsed, or when a reviewer approves it in the review queue on Growth and Scale. Emails in needs_review, duplicates, blocked senders and over-limit emails are not sent. Sandbox emails are not sent either.

Payload and headers

The body is a JSON event. data is the same email object that GET /v1/emails/{id} returns:

POST /webhooks/parsedmail HTTP/1.1
Host: api.yourcompany.example
Content-Type: application/json
User-Agent: ParsedMail-Webhooks/1.0
ParsedMail-Event: email.parsed
ParsedMail-Delivery: dlv_7q2kx0m4ab9c1d3e5f7g
Idempotency-Key: evt_3f9a1c7e0b2d4a6c8e10
ParsedMail-Signature: t=1792061524,v1=5f8a0c1e9d3b7a26f4e1c08b92d7a5e36c4f1b0a8e2d9c7b6a5f4e3d2c1b0a99

{
  "id": "evt_3f9a1c7e0b2d4a6c8e10",
  "type": "email.parsed",
  "created": "2026-10-15T09:12:06+00:00",
  "data": {
    "id": "eml_f0qfttfnggyv5cn4a3ka",
    "object": "email",
    "inbox": "inb_8k2m4q7d1x",
    "inbox_address": "[email protected]",
    "schema": "sch_vnf3updh2qin",
    "schema_version": 3,
    "status": "parsed",
    "received_at": "2026-10-15T09:12:01+00:00",
    "from": {"email": "[email protected]", "name": "Halcyon Cloud Billing"},
    "to": "[email protected]",
    "subject": "Invoice INV-2026-0917",
    "message_id": "[email protected]",
    "duplicate_of": null,
    "attachments": [
      {"filename": "INV-2026-0917.pdf", "content_type": "application/pdf", "size": 48211, "pages": 2, "parsed": true}
    ],
    "data": {
      "invoice_number": "INV-2026-0917",
      "vendor_name": "Halcyon Cloud",
      "due_date": "2026-11-14",
      "line_items": [
        {"description": "Compute plan, 3 nodes", "quantity": 3, "unit_price": {"amount": 140, "currency": "EUR"}},
        {"description": "Managed backups", "quantity": 1, "unit_price": {"amount": 60, "currency": "EUR"}}
      ],
      "total": {"amount": 480, "currency": "EUR"},
      "payment_status": "unpaid"
    },
    "confidence": {"invoice_number": 0.99, "vendor_name": 0.97, "due_date": 0.95, "line_items": 0.93, "total": 0.98, "payment_status": 0.82},
    "confidence_avg": 0.94,
    "needs_review": false,
    "reviewed": false,
    "classification": null,
    "warnings": [],
    "sandbox": false
  }
}
HeaderValue
ParsedMail-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>
ParsedMail-Eventemail.parsed or webhook.test
ParsedMail-DeliveryDelivery id, dlv_..., one per event and endpoint, the same on every automatic retry
Idempotency-KeyThe event id, evt_..., the same on every attempt and every manual retry of this event
User-AgentParsedMail-Webhooks/1.0

After a reviewer corrects values, data.data holds the corrected values and reviewed is true. Field types are described in schema field types.

Verify the signature

The signature is an HMAC-SHA256 of the string <t>.<raw body> with the endpoint secret as key, written as lowercase hex. Compute it over the raw bytes you received, before any JSON parsing, compare in constant time, and reject a t more than 300 seconds away from your clock. The timestamp is set for every attempt, so retries pass this check too.

Node (Express)

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.PARSEDMAIL_WEBHOOK_SECRET;

function verify(rawBody, header) {
  const parts = Object.fromEntries(
    (header || "").split(",").map((p) => p.trim().split("=", 2))
  );
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", SECRET)
    .update(parts.t + ".")
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhooks/parsedmail", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("ParsedMail-Signature"))) return res.sendStatus(400);
  const event = JSON.parse(req.body);
  // queue event.data for processing, then answer quickly
  res.sendStatus(200);
});

Python (Flask)

import hashlib, hmac, json, os, time
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["PARSEDMAIL_WEBHOOK_SECRET"].encode()

def verify(raw: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

@app.post("/webhooks/parsedmail")
def parsedmail():
    raw = request.get_data()
    if not verify(raw, request.headers.get("ParsedMail-Signature", "")):
        abort(400)
    event = json.loads(raw)
    return "", 200

PHP

<?php
$secret = getenv('PARSEDMAIL_WEBHOOK_SECRET');
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_PARSEDMAIL_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $header) as $kv) {
    [$k, $v] = array_pad(explode('=', trim($kv), 2), 2, '');
    $parts[$k] = $v;
}

$t = $parts['t'] ?? '';
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);

if (!ctype_digit($t) || abs(time() - (int) $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
    http_response_code(400);
    exit;
}

$event = json_decode($raw, true);
http_response_code(200);

Go

package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verify(body []byte, header, secret string) bool {
	var t, v1 string
	for _, p := range strings.Split(header, ",") {
		k, v, ok := strings.Cut(strings.TrimSpace(p), "=")
		if !ok {
			continue
		}
		switch k {
		case "t":
			t = v
		case "v1":
			v1 = v
		}
	}
	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return false
	}
	if d := time.Now().Unix() - ts; d > 300 || d < -300 {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(v1))
}

func Handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(io.LimitReader(r.Body, 30<<20))
	if err != nil || !verify(body, r.Header.Get("ParsedMail-Signature"), os.Getenv("PARSEDMAIL_WEBHOOK_SECRET")) {
		http.Error(w, "invalid signature", http.StatusBadRequest)
		return
	}
	// decode body and queue the email for processing
	w.WriteHeader(http.StatusOK)
}

Responses, timeouts and the retry schedule

Any 2xx status is success. Every other status, a timeout or a connection error is a failed attempt. Redirects are not followed, so a 301 or 302 also fails; point the endpoint at the final URL. Each attempt waits up to 15 seconds for an answer (5 seconds to connect). Answer quickly and do the work in a background job.

AttemptSent
1Right after the email is parsed or approved
21 minute after attempt 1 fails
35 minutes after attempt 2
430 minutes after attempt 3
52 hours after attempt 4
612 hours after attempt 5, the last one

After the sixth failed attempt the delivery is marked failed. The parsed JSON stays in the log and in the API, so nothing is lost. If an endpoint URL stops passing the https and public host check, the delivery fails at once without retries. Each endpoint is tracked separately: one failing endpoint does not hold back the others.

Idempotency and ordering

Retries mean your endpoint can see the same email more than once, for example when it saved the data but timed out before answering. Make the handler safe to repeat:

  • Automatic retries of one delivery carry the same ParsedMail-Delivery and the same Idempotency-Key.
  • A manual Retry from the delivery log creates a new delivery with a new ParsedMail-Delivery id, but the body and the Idempotency-Key, which is the event id, stay the same. Store the Idempotency-Key with a unique constraint and every event is processed once.
  • A replay of the email, and Send to webhooks on the email page, create a new event with a new event id and the current result.

The simplest rule is to upsert by data.id, the email id: store the latest result per email and ignore anything older than what you have. Deliveries are not guaranteed to arrive in the order emails were received, so sort by data.received_at when order matters.

INSERT INTO parsed_emails (email_id, payload, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (email_id) DO UPDATE SET payload = EXCLUDED.payload, updated_at = now();

Test events, secret rotation and the delivery log

Send test event sends one signed request with type webhook.test and shows the HTTP status and timing. A test event is tried once, without retries:

{
  "id": "evt_0a1b2c3d4e5f6a7b8c9d",
  "type": "webhook.test",
  "created": "2026-10-15T09:20:11+00:00",
  "data": {"message": "Test event from ParsedMail. Verify the ParsedMail-Signature header with your endpoint secret."}
}

Make your handler answer 2xx to webhook.test without processing it. Rotate secret creates a new whsec_ secret and switches to it immediately, including for retries already scheduled. To rotate without failed deliveries, let your receiver accept a signature made with either the old or the new secret, rotate, deploy the new secret, then remove the old one.

The Webhooks page lists the 30 most recent deliveries with time, event, email, endpoint, status and HTTP code; a failed one has a Retry button. Each email page lists its own deliveries with the attempt number and the time of the next retry, a Retry now button for failed ones, and Send to webhooks, which sends the email again to every active endpoint as a new event.

Failure alerts and no-code tools

On Growth and Scale the workspace owner gets an email when a delivery fails after its last attempt, with the endpoint, the last response and a link to the log. To avoid a flood during an outage, there is at most one alert per endpoint per hour. Starter shows failures in the log without an email.

Zapier, Make and n8n receive ParsedMail webhooks through their built-in triggers: "Webhooks by Zapier" with Catch Hook, "Webhooks" with Custom webhook in Make, and the "Webhook" node with method POST in n8n. Paste the trigger URL as an endpoint and press Send test event so the tool learns the fields. These tools do not check the signature for you; keep the trigger URL private, or add a code step that verifies it. Step-by-step guides are on the integrations page, and the endpoints you can call from HTTP steps are in the API reference.

Frequently asked questions

Which status code should my endpoint return?

Any 2xx, ideally 200 or 204, as soon as the event is stored. Do slow work afterwards; an attempt times out after 15 seconds.

Why does my signature check fail?

The usual cause is computing it over parsed and re-serialized JSON instead of the raw body. Other causes are a rotated secret, a clock that is off by more than five minutes, or a proxy that changes the body.

Can one email go to several endpoints?

Yes. Every active endpoint of the workspace without an inbox filter, plus every endpoint filtered to that inbox, gets its own delivery with its own retries.

Do webhook retries count toward my plan?

No. Usage counts parsed emails, not deliveries. Retries, manual retries and test events are free.

Is there a webhook for emails waiting in review?

No. An email in the review queue is sent once a reviewer approves it, with the corrected values. Rejected emails are not sent.

Related