---
updatedAt: 2026-07-14T09:50:25.000Z
agentTools:
  projectIndex: https://docs.confirmo.net/llms.txt
---

# 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

| 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

```json
{
  "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`**&#x20;equals the&#x20;**`webhook-id`**&#x20;header.** Either is a valid deduplication key.
3. `resourceType`**&#x20;+&#x20;**`resourceId`**&#x20;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&#x20;**`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:

```json
{ "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.

```js
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)

```java
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&#x20;**`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&#x20;**`2xx`**&#x20;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

```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);
  });
}
```

```sql
CREATE TABLE processed_webhooks (
  webhook_id  TEXT PRIMARY KEY,
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

### Java 25

```java
@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&#x20;**`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 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&#x20;**`307`**/**`308`**&#x20;are followed** (these preserve the POST method and body), up
  to a single hop.
* `301`**/**`302`**/**`303`**&#x20;(and other&#x20;**`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).

<br />