Documentation

Receive email as JSON.

Point an address at ReplyFlow and every reply arrives on your webhook as clean, threaded, intent-tagged JSON — quoted history and signatures stripped, signed so you can verify it. No SMTP, no parser, no servers.

Quickstart

  1. Sign in at replyflow.com/login (Google or GitHub).
  2. Grab your address. Onboarding provisions a dedicated inbound address like [email protected]. Send anything to it — replies, confirmations, support.
  3. Set your webhook URL in the dashboard. We POST each parsed email there as JSON, signed with your secret. Hit Send test event to confirm wiring before a single real email lands.

That's it. Reply to an email at your address and watch it appear in your dashboard and on your webhook within seconds.

How it works

An email arrives at your address → ReplyFlow parses the MIME at the edge, strips quoted history and signatures, threads it and tags the intent → we POST the result to your webhook with an HMAC signature → if your endpoint is unreachable we retry automatically with backoff. Every message is also stored, queryable from the dashboard and the read API, and re-deliverable on demand.

The webhook

We send a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
X-ReplyFlow-Eventinbound_email or usage.limit_reached
X-ReplyFlow-TimestampUnix seconds when we signed the request
X-ReplyFlow-Signaturesha256=<hex> — HMAC-SHA256 of timestamp + "." + rawBody using your endpoint's signing secret
User-AgentReplyFlow/1.0

Return any 2xx to acknowledge. Any other status (or a timeout) is treated as a failure and retried.

Payload reference

The inbound_email body:

{
  "id": "msg_3ub1yws37taywewoty86",
  "endpoint_id": "ep_k7m2x9qab4d8e1",
  "to": "[email protected]",
  "from": { "name": "Sarah Chen", "email": "[email protected]" },
  "subject": "Re: Confirm your demo time",
  "thread_id": "thr_9f2c4a1b",
  "text": "Yes, that works for me — let's do Friday at 2pm.",
  "intent": "confirm",
  "signature_removed": true,
  "quoted_removed": true,
  "attachments": [{ "filename": "invoice.pdf", "mime": "application/pdf", "size": 18213 }],
  "received_at": "2026-06-20T11:51:18.004Z"
}
FieldTypeNotes
idstringStable message id.
endpoint_idstringWhich of your addresses received it.
fromobject{ name, email } of the sender.
thread_idstringStable per conversation — group replies by this.
textstringThe cleaned reply: quotes & signatures removed.
intentenumconfirm · decline · question · unsubscribe · out_of_office · reply
signature_removed / quoted_removedboolWhether we stripped a signature / quoted history.
attachmentsarray{ filename, mime, size } metadata per attachment.
received_atISO 8601When the mail arrived.

Verifying signatures

Always verify the signature before trusting a payload. Recompute the HMAC over <timestamp>.<rawBody> with your endpoint's signing secret and compare. Use the raw request body (not a re-serialized object).

Node.js

import crypto from "node:crypto";

function verify(rawBody, headers, secret) {
  const ts  = headers["x-replyflow-timestamp"];
  const sig = headers["x-replyflow-signature"];        // "sha256=..."
  const mac = crypto.createHmac("sha256", secret)
    .update(ts + "." + rawBody).digest("hex");
  const expected = "sha256=" + mac;
  if (typeof sig !== "string" || sig.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

Cloudflare Workers / edge (Web Crypto)

async function verify(rawBody, headers, secret) {
  const ts  = headers.get("x-replyflow-timestamp");
  const sig = headers.get("x-replyflow-signature");    // "sha256=..."
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const buf = await crypto.subtle.sign("HMAC", key,
    new TextEncoder().encode(ts + "." + rawBody));
  const mac = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
  const expected = "sha256=" + mac;
  if (typeof sig !== "string" || sig.length !== expected.length) return false;
  let diff = 0;                                         // constant-time compare
  for (let i = 0; i < expected.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
  return diff === 0;
}

Python

import hmac, hashlib

def verify(raw_body: bytes, headers, secret: str) -> bool:
    ts  = headers["x-replyflow-timestamp"]
    sig = headers["x-replyflow-signature"]              # "sha256=..."
    mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(sig, "sha256=" + mac)

Event types

Read X-ReplyFlow-Event to route:

  • inbound_email — a parsed reply (schema above).
  • usage.limit_reached — sent once per billing period when you hit your monthly email cap. Inbound delivery pauses (messages are still stored, visible in the dashboard and read API) until you upgrade or the period resets. Body:
    {
      "type": "usage.limit_reached",
      "endpoint_id": "ep_k7m2x9qab4d8e1",
      "period": "2026-06",
      "used": 100,
      "limit": 100
    }

Read API

Fetch your messages programmatically. Create an API key under Dashboard → Settings.

GET https://replyflow.com/api/v1/messages

curl https://replyflow.com/api/v1/messages?limit=20 \
  -H "Authorization: Bearer rf_live_xxx"
Query paramNotes
endpointFilter to one address (its ep_… id).
limit1–100, default 50.
beforeISO timestamp cursor for pagination (pass back the previous cursor).

Response: { "object": "list", "data": [ …messages… ], "cursor": "…" }. Each item is the full payload plus delivery_status. Calls count toward your plan's monthly API quota.

Errors. 401 — the Authorization: Bearer rf_live_… header is missing or the key is invalid. 429 — your monthly API-call quota is exhausted; back off and retry after the Retry-After header (seconds; the quota resets at the start of each month).

Plans & limits

PlanEmails / moAPI calls / moPrice
Free1001,000€0
Pro2,00020,000€19
Scale10,000100,000€49

Prices exclude VAT. Over the monthly email cap, inbound is stored but not delivered to your webhook (you get a usage.limit_reached event); it resumes when you upgrade or the period resets.

Start free