Merchant Webhook Integration Guide

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
    v1a asymmetric 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 2xx after 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

HeaderSigned?Meaning
webhook-idyesUUID v7; per-delivery idempotency key. Changes on replay.
webhook-timestampyesUnix seconds at send time; re-stamped on retry/replay.
webhook-signaturen/av1a,<base64 Ed25519 signature> (a space-separated list is allowed).
webhook-typenoUnsigned mirror of body.type; routing hint only.
webhook-sequencenoUnsigned mirror of body.sequence; telemetry hint only.
user-agentnoConfirmo-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

  1. Make every security and ordering decision on the signed body, never on the
    webhook-type / webhook-sequence headers. Those headers are unsigned and
    may not reach a downstream processing core.
  2. body.id equals the webhook-id header. Either is a valid deduplication key.
  3. resourceType + resourceId form the ordering partition — not the primary
    key inside data. The routed resource is not always the entity inside data
    (for example, a payment event 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).

  1. Fetch and cache the JWKS from https://api.confirmo.com/.well-known/jwks.json.
  2. Select keys with use:"sig", kty:"OKP", crv:"Ed25519". The signature has
    no embedded kid, and during a key rotation overlap there may be two
    keys
    — try each (Ed25519 verification is cheap).
  3. Verify over {webhook-id}.{webhook-timestamp}.{rawBody}.
  4. 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 edSpecFromRawX is the
fiddly part of the JDK-native path. Integrators already using BouncyCastle can replace
key loading with new Ed25519PublicKeyParameters(Base64.getUrlDecoder().decode(x), 0)
and verify with Ed25519Signer.

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 (== the webhook-id header). 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 2xx for duplicates (and for stale drops). Any non-2xx makes 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 sequence exceeds the last applied sequence for
    that resource. Drop older or reordered arrivals — and still return 2xx.
  • 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 responseOur action
2xxsuccess — delivery complete
408, 425, 429, any 5xx, or a network/TLS/timeout errorretry
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):

AttemptWhen
1immediate
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/308 are followed (these preserve the POST method and body), up
    to a single hop.
  • 301/302/303 (and other 3xx) 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 (still 2xx); 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
    kid on the signature — try every use:"sig" Ed25519 key in the JWKS.
  • base64 variant mismatch. The signature value is standard base64; the
    JWKS x is 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-timestamp is 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 on sequence (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 used 301/302/303 (only 307/308
    are followed). Point redirects only at public HTTPS endpoints (Section 8).