ParasendDocs
Sign in Start free

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_endpoints. 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 or

    password 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 localhost or *.local, and names that resolve to private, loopback or link-local addresses (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_url_unresolvable or webhook_url_port_not_allowed — and again on every connection. If the name later resolves to a non-public address, Parasend does not connect; the attempt fails with the error destination_not_public and is retried on the usual schedule.

  • The OAuth 2.0 token_url of an endpoint follows the same rules.

Which events an endpoint gets

An endpoint gets the events of its own mode (test or live) whose type it subscribes to (event_types; 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_sources:

{ "delivery_sources": { "api_keys": ["key_01K7C5Y8A0B1C2D3E4F5G6H7J8"], "portal": false } }
  • api_keys: deliveries sent with these keys. A key keeps matching after it is rolled or passed to a new holder.
  • 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: true marks a request you replayed by hand; Parasend-Test: true marks a test event.
  • Authorization is added when you configured a static bearer token or OAuth 2.0 client credentials on the

    endpoint.

  • delivery.email_opened is indicative only. It comes from an image in the e-mail. Opens that look automatic are

    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.opened (the recipient opened the link and chose a way to sign in) and delivery.confirmed 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.

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_endpoints/{id}/attempts shows every attempt and its outcome.

  • Delivery is at least once. The same event can arrive more than once: keep the event id (also in

    Parasend-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 the

    resource'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:

  1. 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.

  2. Split the header into t and v1.
  3. Reject the request if t is more than 5 minutes from your clock. This stops someone from replaying a captured

    request later. (A replay you trigger yourself is signed afresh, with a new t.)

  4. Compute HMAC-SHA256 of t, a full stop, and the raw body, with your secret as the key, and compare it to v1

    in 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.get_data() 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_endpoints/{id}/test sends a signed test event to your endpoint — delivery.created by default, or any event type you name in {"event_type": "…"}. 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_endpoints/{id}/roll_secret, or "Endurnýja" in the portal): the new secret is shown once, and the previous one keeps signing for 24 hours.