Confirmo delivers signed subscription.* webhooks to your endpoint over public
HTTPS. This guide shows you how to verify, deduplicate, order, and
operate them in production.
1. Overview
- Delivery is at-least-once with no global ordering. Expect the same logical
event more than once, and expect events to arrive out of order. Your endpoint
must be safe under both — this guide shows how. - Every webhook is signed with Ed25519 using the Standard Webhooks
v1aasymmetric scheme. You verify each delivery against Confirmo's public
keys, published as a JWKS. - You make every decision from the signed request body, never from the
unsigned convenience headers. - You acknowledge fast (return
2xxafter verifying and enqueueing) and do heavy
work asynchronously, because the dispatcher enforces a request deadline
(Section 6).
The rest of this guide walks the request shape (Section 2), signature
verification (Section 3), exactly-once processing (Section 4), ordering
(Section 5), and the operational contract — retries, timeouts, rate limiting,
redirection, and replays (Sections 6–9) — plus a troubleshooting checklist
(Section 10).
2. The webhook request
Each delivery is POST <your notifyUrl> with Content-Type: application/json.
Headers
| Header | Signed? | Meaning |
|---|---|---|
webhook-id | yes | UUID v7; per-delivery idempotency key. Changes on replay. |
webhook-timestamp | yes | Unix seconds at send time; re-stamped on retry/replay. |
webhook-signature | n/a | v1a,<base64 Ed25519 signature> (a space-separated list is allowed). |
webhook-type | no | Unsigned mirror of body.type; routing hint only. |
webhook-sequence | no | Unsigned mirror of body.sequence; telemetry hint only. |
user-agent | no | Confirmo-Webhook-Dispatcher/1.0. |
Body — the signed envelope
{
"id": "01HG7AAB...",
"type": "subscription.activated",
"timestamp": "2026-05-29T08:00:00Z",
"sequence": 7,
"resourceType": "subscription",
"resourceId": "sub_7Hn2KpQ9",
"data": { "...": "the bare REST DTO: Subscription, Payment, ..." }
}Three rules that keep you safe
- Make every security and ordering decision on the signed body, never on the
webhook-type/webhook-sequenceheaders. Those headers are unsigned and
may not reach a downstream processing core. body.idequals thewebhook-idheader. Either is a valid deduplication key.resourceType+resourceIdform the ordering partition — not the primary
key insidedata. The routed resource is not always the entity insidedata
(for example, apaymentevent whose routed resource is the parent subscription).
3. Verify the signature
Verify every delivery before trusting it. The signature is Ed25519 over the
exact byte string {webhook-id}.{webhook-timestamp}.{body}, where body is
the raw request body bytes — never the re-serialized parsed JSON (re-encoding
reorders keys and breaks the signature).
- Fetch and cache the JWKS from
https://api.confirmo.com/.well-known/jwks.json. - Select keys with
use:"sig",kty:"OKP",crv:"Ed25519". The signature has
no embeddedkid, and during a key rotation overlap there may be two
keys — try each (Ed25519 verification is cheap). - Verify over
{webhook-id}.{webhook-timestamp}.{rawBody}. - Required: reject if
now − webhook-timestamp > 5 minutes(300 seconds) —
a replay-window check. Deduplication (Section 4) alone is not sufficient
because a captured webhook can be replayed with a new delivery attempt.
A JWKS entry looks like:
{ "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA",
"kid": "2026-05", "x": "base64url raw 32-byte public key" }Node.js (vanilla)
The reference code below uses CommonJS (require / module.exports). If your
project is ESM, change the first line to import crypto from "node:crypto"; and
drop the final module.exports line (use export { getVerifyKeys, verify };) —
the verification logic is identical.
const crypto = require("node:crypto");
// --- 1. JWKS (cache; refresh on unknown key) ---
async function getVerifyKeys(jwksUrl) {
const { keys } = await (await fetch(jwksUrl)).json();
return keys
.filter((k) => k.kty === "OKP" && k.crv === "Ed25519" && k.use === "sig")
.map((k) => crypto.createPublicKey({ key: k, format: "jwk" }));
}
// --- 2. Verify over `{id}.{timestamp}.{rawBody}` against any active key ---
function verify({ headers, rawBody, keys }) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"]; // "v1a,<b64> v1a,<b64> ..."
if (!ts || isNaN(Number(ts))) throw new Error("missing or invalid timestamp");
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale timestamp");
const signed = Buffer.from(`${id}.${ts}.${rawBody}`, "utf8");
const sigs = sigHeader.split(" ")
.filter((p) => p.startsWith("v1a,"))
.map((p) => Buffer.from(p.slice(4), "base64"));
const ok = sigs.some((sig) => keys.some((key) => crypto.verify(null, signed, key, sig)));
if (!ok) throw new Error("invalid signature");
}
// --- 3. Edge handler: verify, then hand the PARSED body to the core ---
async function handle(req, res, keys) {
const rawBody = req.rawBody; // exact bytes as received; do NOT re-stringify
try {
verify({ headers: req.headers, rawBody, keys });
} catch {
return res.status(400).end(); // bad signature => non-retryable for us
}
await process(JSON.parse(rawBody)); // core never needs the headers again
return res.status(200).end();
}
module.exports = { getVerifyKeys, verify, handle };Java 25 (vanilla, JDK-native)
import java.net.URI;
import java.net.http.*;
import java.security.*;
import java.security.spec.*;
import java.time.Duration;
import java.util.*;
import com.fasterxml.jackson.databind.*;
public final class ConfirmoWebhooks {
private static final ObjectMapper M = new ObjectMapper();
private static final long SKEW_SECONDS = 300;
// 1. Load Ed25519 public keys from the JWKS (cache; refresh on unknown key).
static List<PublicKey> loadKeys(String jwksUrl) throws Exception {
JsonNode keys = M.readTree(HttpClient.newHttpClient()
.send(HttpRequest.newBuilder(URI.create(jwksUrl))
.timeout(Duration.ofSeconds(5)).build(),
HttpResponse.BodyHandlers.ofString()).body()).get("keys");
List<PublicKey> out = new ArrayList<>();
KeyFactory kf = KeyFactory.getInstance("Ed25519");
for (JsonNode k : keys) {
if (!"OKP".equals(k.path("kty").asText()) || !"Ed25519".equals(k.path("crv").asText())
|| !"sig".equals(k.path("use").asText())) continue;
out.add(kf.generatePublic(edSpecFromRawX(
Base64.getUrlDecoder().decode(k.get("x").asText()))));
}
return out;
}
// JWKS `x` is the raw 32-byte little-endian public key; the high bit of the
// last byte is the x-coordinate sign. Reverse to big-endian for EdECPoint.
static EdECPublicKeySpec edSpecFromRawX(byte[] x) {
byte[] le = x.clone();
boolean xOdd = (le[le.length - 1] & 0x80) != 0;
le[le.length - 1] &= (byte) 0x7f;
for (int i = 0, j = le.length - 1; i < j; i++, j--) { byte t = le[i]; le[i] = le[j]; le[j] = t; }
return new EdECPublicKeySpec(NamedParameterSpec.ED25519, new EdECPoint(xOdd, new java.math.BigInteger(1, le)));
}
// 2. Verify over `{id}.{timestamp}.{rawBody}` against any active key.
public static void verify(Map<String, String> headers, byte[] rawBody, List<PublicKey> keys) throws Exception {
String id = headers.get("webhook-id"), ts = headers.get("webhook-timestamp");
if (id == null || ts == null) throw new SecurityException("missing required headers");
long tsNum;
try {
tsNum = Long.parseLong(ts);
} catch (NumberFormatException e) {
throw new SecurityException("invalid timestamp");
}
if (Math.abs(System.currentTimeMillis() / 1000 - tsNum) > SKEW_SECONDS)
throw new SecurityException("stale timestamp");
byte[] signed = (id + "." + ts + ".").getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] message = concat(signed, rawBody);
for (String part : headers.get("webhook-signature").split(" ")) {
if (!part.startsWith("v1a,")) continue;
byte[] sig = Base64.getDecoder().decode(part.substring(4));
for (PublicKey key : keys) {
Signature s = Signature.getInstance("Ed25519");
s.initVerify(key); s.update(message);
if (s.verify(sig)) return;
}
}
throw new SecurityException("invalid signature");
}
static byte[] concat(byte[] a, byte[] b) {
byte[] r = Arrays.copyOf(a, a.length + b.length);
System.arraycopy(b, 0, r, a.length, b.length);
return r;
}
}Java raw-key decode: the little-endian reversal in
edSpecFromRawXis the
fiddly part of the JDK-native path. Integrators already using BouncyCastle can replace
key loading withnew Ed25519PublicKeyParameters(Base64.getUrlDecoder().decode(x), 0)
and verify withEd25519Signer.
Alternative: the Standard Webhooks SDK
The signature is Standard Webhooks v1a (asymmetric Ed25519). The standardwebhooks
SDK's asymmetric Ed25519 verification could not be confirmed against a live signature
in this release — the current JS port handles v1 (HMAC-SHA256) only and skips
v1a signatures. Until an asymmetric-capable SDK release is verified, use the
vanilla Node.js / Java 25 path above — it is the canonical, tested reference for
our v1a signature.
4. Process exactly once
Verify first, then process. Keep the two steps separate: the edge verifies the
signature on the raw body; the core deduplicates and orders from the body
fields alone (it never needs the headers again).
- Deduplicate on
body.id(== thewebhook-idheader). The id is stable
across every retry of the same logical message; a replay gets a new
id (Section 9), so deduplication never suppresses an intentional replay. - Insert-the-id and process in one transaction, so a mid-processing crash
doesn't leave the id marked done. - Return
2xxfor duplicates (and for stale drops). Any non-2xxmakes us retry. - Retain ids for ≥ 24 h + margin (the full retry window) — a few days is comfortable.
Node.js
// --- Core: dedup on body.id + last-writer-wins ordering, one transaction ---
async function process(evt) {
const { id, resourceType, resourceId, sequence, data } = evt;
await db.tx(async (t) => {
try {
await t.insert("processed_webhooks", { webhook_id: id }); // PK conflict => duplicate
} catch (e) {
if (isUniqueViolation(e)) return; // already processed
throw e;
}
const last = await t.lastSequence(resourceType, resourceId);
if (last != null && sequence <= last) return; // stale / reordered => drop (still 200)
await t.upsertResource(resourceType, resourceId, sequence, data);
});
}CREATE TABLE processed_webhooks (
webhook_id TEXT PRIMARY KEY,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Java 25
@Transactional
public void process(WebhookEnvelope evt) {
try {
processedWebhooks.insert(evt.id()); // PK conflict => duplicate
} catch (DuplicateKeyException dup) {
return; // already processed
}
Long last = resources.lastSequence(evt.resourceType(), evt.resourceId());
if (last != null && evt.sequence() <= last) return; // stale / reordered => drop (still 200)
resources.upsert(evt.resourceType(), evt.resourceId(), evt.sequence(), evt.data());
}5. Apply events in order
Ordering is last-writer-wins on body.sequence, partitioned by
(body.resourceType, body.resourceId):
- Apply an event only if its
sequenceexceeds the last applied sequence for
that resource. Drop older or reordered arrivals — and still return2xx. - This is exactly the
lastSequence(...)comparison in the Section 4 snippet:
the deduplication and ordering checks run together inside the one transaction. - Gaps are normal. Not every internal event becomes a webhook, so sequence
numbers are monotonic but not contiguous. Never wait for a missing number —
only monotonicity matters.
6. Delivery, retries & timeouts
Delivery is at-least-once with no global ordering — expect duplicates and
reordering, and rely on Sections 4–5 to absorb them.
Respond within the deadline
The dispatcher enforces separate connect and read deadlines: connect 5 s
and read 20 s — about 25 s total (both env-overridable). Acknowledge
fast: return 2xx as soon as
you have verified and enqueued the event, then do heavy work asynchronously. A
connect or read timeout is treated as a network error → retry.
What your HTTP status means to us
| Your response | Our action |
|---|---|
2xx | success — delivery complete |
408, 425, 429, any 5xx, or a network/TLS/timeout error | retry |
any other 4xx (400/401/403/404/410/422 …) | give up immediately (terminal) |
Backoff schedule
About 10 attempts spread over ~24 h (cumulative wall-clock from the first attempt):
| Attempt | When |
|---|---|
| 1 | immediate |
| 2 | +5 s |
| 3 | +5 min |
| 4 | +30 min |
| 5 | +2 h |
| 6 | +5 h |
| 7 | +10 h |
| 8 | +14 h |
| 9 | +20 h |
| 10 | +24 h (we give up after this) |
After exhaustion the delivery is terminal. In short: sustained 4xx ⇒ we stop
trying immediately; transient failures ⇒ we retry for ~24 h, then stop.
7. Rate limiting
We cap delivery to a single endpoint at about 300 sends per minute by default
(a fixed 60-second window per merchant, counting attempted sends).
- Bursts above the cap are never dropped and never failed — they are deferred
via the normal retry verdict and spread across the backoff schedule (Section 6),
so they arrive slightly later.
You can apply your own backpressure: return 429 (optionally with
Retry-After) and we classify it as retry and back off per Section 6. This
is complementary to our outbound cap.
8. Redirection & endpoint requirements
Register a terminal, public HTTPS endpoint as your notifyUrl. The dispatcher
follows redirects only under strict rules:
- Only
307/308are followed (these preserve the POST method and body), up
to a single hop. 301/302/303(and other3xx) are not followed → retry, not delivery
(following them would rewrite the request to a bodyless GET).- The signature is unchanged across hops — it covers
{webhook-id}.{webhook-timestamp}.{body}, not the URL — so the redirected
re-POST carries identical headers, body, and signature (no re-sign). - Each hop consumes your rate-limit budget: one redirecting attempt costs
two tokens (Section 7).
Don't rely on redirects — each hop adds latency and consumes rate-limit budget
(Section 7). Point
notifyUrl (and any redirect target) only at a public HTTPS endpoint.
9. Replays
A replay is a genuinely new message: a new webhook-id / body.id, a fresh
webhook-timestamp and signature — but sequence preserved (same
body.sequence as the original), as is body.timestamp (the event-occurrence time).
Consequences for you:
- Do not deduplicate a replay against the original id — it has a new id, by
design. (Section 4 dedup keys on the new id and so won't suppress it.) - Ordering still does the right thing: if you already advanced past that
sequence, the replay is stale and you drop it (still2xx); if you missed it,
you apply it.
10. Troubleshooting / FAQ
Signature verification fails on a delivery I believe is genuine.
- You are verifying over a re-serialized body. Verify over the raw request
bytes; re-encoding the parsed JSON reorders keys and breaks the signature. - You selected the wrong key. During rotation there can be two keys with no
kidon the signature — try everyuse:"sig"Ed25519 key in the JWKS. - base64 variant mismatch. The signature value is standard base64; the
JWKSxis base64url. Decode each with the matching decoder.
I get "stale timestamp" rejections.
- Your server clock has drifted, or you set the replay window too tight. The
webhook-timestampis in Unix seconds; allow ~5 minutes (300 s) of skew.
I see duplicate or out-of-order deliveries ("duplicate storms").
- Expected — delivery is at-least-once with no global ordering. Deduplicate on
body.id(Section 4) and apply last-writer-wins onsequence(Section 5).
My redirect was rejected and the delivery was given up.
- The redirect target was non-HTTPS, embedded credentials, or resolved to a
private/internal address, or you used301/302/303(only307/308
are followed). Point redirects only at public HTTPS endpoints (Section 8).

