Receiving webhooks
Parasend tells your system about delivery events (created, notified, opened, confirmed, bounced, sealed …) by sending an HTTPS POST to the endpoint you register with POST /v1/webhook_. This guide covers what arrives, how to answer, and how to verify that a request really came from Parasend.
The verification code below is tested in Parasend's own CI against the code that signs the requests, so it stays correct as the service evolves.
Your endpoint's address
- HTTPS on port 443, to a host name. Not plain
http, not another port, not an IP address, and no user name orpassword in the URL.
- Public addresses only. Every address the host name resolves to (A and AAAA) must be on the public internet.
Names such as
localhostor*., and names that resolve to private, loopback or link-local addresses (local 10.,x 192.,168. x 127.,x 169.,254. x fc00::/7…), are refused. - Checked twice. When you register the endpoint or change its URL — the request is refused with
webhook_,url_ not_ public webhook_orurl_ unresolvable webhook_— and again on every connection. If the name later resolves to a non-public address, Parasend does not connect; the attempt fails with the errorurl_ port_ not_ allowed destination_and is retried on the usual schedule.not_ public - The OAuth 2.0
token_of an endpoint follows the same rules.url
Which events an endpoint gets
An endpoint gets the events of its own mode (test or live) whose type it subscribes to (event_; empty means every type). By default that is every delivery's events, whichever API key or person sent it.
To limit an endpoint to some senders (Uppruni in the portal), set delivery_:
{ "delivery_sources": { "api_keys": ["key_01K7C5Y8A0B1C2D3E4F5G6H7J8"], "portal": false } }
api_: deliveries sent with these keys. A key keeps matching after it is rolled or passed to a new holder.keys portal: true: also deliveries that members sent from the portal.null(the default) means every delivery.- Which events are filtered. Only events tied to a delivery are filtered, by that delivery's sender. Events not
tied to a delivery always go through.
- When a change applies. A change applies to events from then on. Retries already queued still go ahead.
- Test and replay. A test event or a replay always reaches the endpoint.
What you receive
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Parasend-Webhooks/1.0 (+https://parasend.com/docs/webhooks)
Parasend-Signature: t=1791737791,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Parasend-Event-Id: evt_01K7C5Z2W8G9S1V3N4T6R8Y0QX
Parasend-Event-Type: delivery.confirmed
{"id":"evt_01K7C5Z2W8G9S1V3N4T6R8Y0QX","object":"event","type":"delivery.confirmed","created":"2026-10-11T16:56:31.000Z","resource":{"type":"delivery","id":"dlv_01K7C5Y8A0B1C2D3E4F5G6H7J8"},"batch_id":null,"service_level":"standard","reason":null,"data":null}
- The body is thin. It names the event and the resource — never the recipient's personal data or file names. Fetch
the resource (
GET /v1/deliveries/{id}) with your API key when you need its current state. Parasend-Replay: truemarks a request you replayed by hand;Parasend-Test: truemarks a test event.Authorizationis added when you configured a static bearer token or OAuth 2.0 client credentials on theendpoint.
delivery.is indicative only. It comes from an image in the e-mail. Opens that look automatic areemail_ opened left out: ones Amazon SES flags as a bot, Apple Mail loading every image in advance, known automated programs, and opens within a minute of the e-mail arriving (security scanners). Some can still get through, and mail programs that block images report no open at all.
delivery.(the recipient opened the link and chose a way to sign in) andopened delivery.are the events that carry weight. Loading the page alone is not an open: mail-security scanners load links in a browser before anyone reads the e-mail.confirmed
How to answer
- Answer with any 2xx status within 10 seconds. Anything else — another status, a timeout, a redirect (redirects
are not followed) — counts as a failed attempt.
- Answer first, work later. Store the event and process it in the background, so a slow job never causes a retry.
Retries, duplicates and order
- A failed attempt is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, 24 hours and
24 hours — nine attempts over about three days. After that the event stays in the endpoint's log, your organisation's admins are told, and you can send it again with
POST /v1/webhook_.endpoints/{id}/replay GET /v1/webhook_shows every attempt and its outcome.endpoints/{id}/attempts - Delivery is at least once. The same event can arrive more than once: keep the event
id(also inParasend-Event-Id) and ignore one you have already processed. - Order is not guaranteed. A retry can arrive after a later event. Order by
created, or simply fetch theresource's current state when an event arrives.
- A replay ends the retries. When a replay gets a 2xx answer, Parasend stops any retries still due for that
event on that endpoint.
Verifying the signature
Every request is signed with your endpoint's signing secret (shown once, when you create the endpoint):
Parasend-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
For 24 hours after you roll the secret, the header carries two v1 values — one made with the new secret, one with the previous — so you can switch at your own pace. Accept the request when any v1 matches the secret you hold.
To verify:
- Take the raw body bytes exactly as received. Do not parse and re-serialise the JSON first — any change in
spacing or key order breaks the signature.
- Split the header into
tandv1. - Reject the request if
tis more than 5 minutes from your clock. This stops someone from replaying a capturedrequest later. (A replay you trigger yourself is signed afresh, with a new
t.) - Compute HMAC-SHA256 of
t, a full stop, and the raw body, with your secret as the key, and compare it tov1in constant time.
Answer 400 when verification fails, and do not process the event.
Node.js
// file: docs/examples/verify-webhook.mjs
// Verify a Parasend webhook signature — Node.js 18+, no dependencies.
import { createHmac, timingSafeEqual } from 'node:crypto';
export const TOLERANCE_SECONDS = 5 * 60;
/**
* True only if `header` (the Parasend-Signature value) is a valid signature of `rawBody` — the exact bytes received,
* before any JSON parsing — made with `secret` within the last five minutes. Never throws on bad input.
* For 24 hours after a secret roll the header carries two v1 values, one per secret: any one may match.
*/
export function verifyParasendSignature(secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000), toleranceSeconds = TOLERANCE_SECONDS) {
if (!header) return false;
let t = NaN;
const v1s = [];
for (const item of header.split(',')) {
const i = item.indexOf('=');
if (i <= 0) return false;
const key = item.slice(0, i).trim();
const value = item.slice(i + 1).trim();
if (key === 't') t = Number(value);
else if (key === 'v1') v1s.push(value);
}
if (!Number.isInteger(t) || v1s.length === 0 || !v1s.every((v) => /^[0-9a-f]{64}$/.test(v))) return false;
if (Math.abs(nowSeconds - t) > toleranceSeconds) return false; // too old, or from the future: a replayed request
const body = typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : rawBody;
const expected = createHmac('sha256', secret).update(`${t}.`).update(body).digest();
return v1s.some((v) => timingSafeEqual(Buffer.from(v, 'hex'), expected)); // each comparison in constant time
}
With Express, read the body raw on the webhook route only:
import express from 'express';
import { verifyParasendSignature } from './verify-webhook.mjs';
const app = express();
app.post('/webhooks/parasend', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyParasendSignature(process.env.PARASEND_WEBHOOK_SECRET, req.get('Parasend-Signature'), req.body)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// store `event` (deduplicate on event.id), then process it in the background
res.sendStatus(204);
});
Python
# file: docs/examples/verify_webhook.py
"""Verify a Parasend webhook signature - Python 3.8+, standard library only."""
import hashlib
import hmac
import time
from typing import Optional
TOLERANCE_SECONDS = 5 * 60
HEX = set("0123456789abcdef")
def verify_parasend_signature(secret: str, header: Optional[str], raw_body: bytes,
now: Optional[int] = None, tolerance: int = TOLERANCE_SECONDS) -> bool:
"""True only if `header` (the Parasend-Signature value) is a valid signature of `raw_body` - the exact
bytes received, before any JSON parsing - made with `secret` within the last five minutes. For 24 hours
after a secret roll the header carries two v1 values, one per secret: any one may match."""
if not header:
return False
t_value, v1s = "", []
for item in header.split(","):
key, sep, value = item.partition("=")
if not sep or not key.strip():
return False
if key.strip() == "t":
t_value = value.strip()
elif key.strip() == "v1":
v1s.append(value.strip())
try:
t = int(t_value)
except ValueError:
return False
if not v1s or any(len(v1) != 64 or not set(v1) <= HEX for v1 in v1s):
return False
now = int(time.time()) if now is None else now
if abs(now - t) > tolerance: # too old, or from the future: a replayed request
return False
expected = hmac.new(secret.encode("utf-8"), str(t).encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, v1) for v1 in v1s) # each comparison in constant time
With Flask, request. gives the raw bytes:
import json
import os
from flask import Flask, request
from verify_webhook import verify_parasend_signature
app = Flask(__name__)
@app.post("/webhooks/parasend")
def parasend_webhook():
raw = request.get_data()
if not verify_parasend_signature(os.environ["PARASEND_WEBHOOK_SECRET"], request.headers.get("Parasend-Signature"), raw):
return "", 400
event = json.loads(raw)
# store `event` (deduplicate on event["id"]), then process it in the background
return "", 204
Testing your endpoint
POST /v1/webhook_ sends a signed test event to your endpoint — delivery. by default, or any event type you name in {"event_. It carries Parasend-Test: true, and its outcome appears in the endpoint's attempts.
Keep the secret secret
Store the signing secret like a password — in your secret manager, never in source code or logs. Anyone who has it can make requests that pass verification. If it may have leaked, roll it (POST /v1/webhook_, or "Endurnýja" in the portal): the new secret is shown once, and the previous one keeps signing for 24 hours.