A webhook receiver can verify the signature and still do the same business work twice.

That is the failure this guide is meant to prevent. Signature verification proves that the request body came from the expected sender. Idempotency verification proves that this delivery should create one accepted business result, even if the same webhook arrives again.

This post shows one PostgreSQL-backed pattern for signed webhook receivers in Node.js, Python, and Go. It fits MailWebhook endpoint deliveries, where each request is sent as JSON and includes X-MailWebhook-Signature and X-Idempotency-Key. The same storage pattern also applies to many webhook systems that send a stable idempotency key or event id.

If you want the broader receiver walkthrough first, start with Email to Webhook in Node.js, Python, and Go. If you want the reliability model before the implementation, read Idempotency Keys for Email Webhook Automation. This article zooms into the PostgreSQL SQL layer and the duplicate-control code behind a receiver for an email webhook API.

What the receiver has to verify

The reliable receiver has four jobs before business logic runs:

  1. Read the raw request body.
  2. Verify the webhook signature against those exact bytes.
  3. Read X-Idempotency-Key.
  4. Atomically claim that key in durable storage.

The order matters. HMAC verification depends on the exact request bytes, so JSON parsing belongs after signature verification. Duplicate control also belongs before side effects, because the second delivery must not create a second ticket, refund, account update, or notification.

Webhook receiver verification order concept image

For MailWebhook, the endpoint contract gives the receiver the right control fields:

Content-Type: application/json
X-MailWebhook-Signature: t=<unix>, kid=<kid>, v1=<base64(hmac_sha256(t+"."+body, secret))>
X-Idempotency-Key: <sha256(message_id|route_id)>
Idempotency-Key: <same value, when included as a compatibility alias>

Use the signature to verify authenticity. Use the idempotency key to control repeated delivery. Store both the key and a request fingerprint so you can detect accidental key reuse with a different payload.

PostgreSQL schema

A small PostgreSQL table is enough for the idempotency gate. It records the delivery key, a hash of the raw request body, and the response that duplicate deliveries should receive.

CREATE TABLE webhook_idempotency (
    idempotency_key text PRIMARY KEY,
    request_hash char(64) NOT NULL,
    status text NOT NULL CHECK (status IN ('accepted', 'completed', 'failed')),
    response_status integer NOT NULL DEFAULT 202,
    response_body jsonb NOT NULL DEFAULT '{"status":"accepted"}'::jsonb,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now(),
    completed_at timestamptz
);

CREATE INDEX webhook_idempotency_created_at_idx
    ON webhook_idempotency (created_at);

If the receiver only needs to accept and enqueue work, accepted is enough for the HTTP path. A worker can later update the record to completed or failed.

If the receiver performs the business mutation inside the same database transaction, you can mark the record completed before returning. For webhooks that call external APIs, use an outbox or job table so the database commit records both the idempotency claim and the work to perform.

Here is a minimal outbox table:

CREATE TABLE webhook_jobs (
    id bigserial PRIMARY KEY,
    idempotency_key text NOT NULL
        REFERENCES webhook_idempotency (idempotency_key),
    payload jsonb NOT NULL,
    status text NOT NULL DEFAULT 'queued'
        CHECK (status IN ('queued', 'processing', 'completed', 'failed')),
    attempts integer NOT NULL DEFAULT 0,
    created_at timestamptz NOT NULL DEFAULT now(),
    updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX webhook_jobs_status_created_at_idx
    ON webhook_jobs (status, created_at);

The outbox keeps the HTTP receiver thin. The endpoint verifies, claims, stores, and returns. A worker handles slower business work after the webhook has been safely accepted.

PostgreSQL idempotency and outbox schema concept image

Atomic claim SQL

The idempotency claim must be a single database decision. PostgreSQL gives you that decision with a primary key and INSERT ... ON CONFLICT DO NOTHING. The RETURNING clause tells the receiver whether this request created the record. (PostgreSQL INSERT documentation)

WITH claim AS (
    INSERT INTO webhook_idempotency (
        idempotency_key,
        request_hash,
        status
    )
    VALUES ($1, $2, 'accepted')
    ON CONFLICT (idempotency_key) DO NOTHING
    RETURNING idempotency_key
),
job AS (
    INSERT INTO webhook_jobs (idempotency_key, payload)
    SELECT idempotency_key, $3::jsonb
    FROM claim
    RETURNING id
)
SELECT
    EXISTS (SELECT 1 FROM claim) AS claimed,
    (SELECT id FROM job) AS job_id,
    idem.request_hash,
    idem.status,
    idem.response_status,
    idem.response_body
FROM webhook_idempotency AS idem
WHERE idem.idempotency_key = $1;

This query gives the receiver one of three useful outcomes:

  • claimed = true: this is the first accepted delivery, and a job was queued.
  • claimed = false with the same request_hash: this is a duplicate delivery, so return the stored response.
  • claimed = false with a different request_hash: the same key was reused for different content, so return 409 Conflict.

That last case is worth keeping. Stripe documents the same guardrail for idempotency keys: repeated requests should match the original request parameters. For webhook receivers, a request hash gives you the same protection with a small table column. (Stripe idempotent requests documentation)

Atomic idempotency claim with PostgreSQL concept image

Receiver examples in Node.js, Python, and Go

The three receivers below use the same SQL contract. Each one preserves the raw request body, verifies the HMAC signature, hashes the request bytes, claims X-Idempotency-Key in PostgreSQL, and returns the stored response for safe duplicates.

This Express example uses three pieces:

  • express.raw() so the signature verifier receives the exact body bytes.
  • Node’s crypto.timingSafeEqual() for constant-time HMAC comparison. (Node.js crypto documentation)
  • PostgreSQL ON CONFLICT for the atomic idempotency claim.

It assumes a single active signing secret. If you rotate secrets by kid, replace signingSecret with a lookup that returns the secret for the header’s key id.

import crypto from "node:crypto";
import express from "express";
import pg from "pg";

const { Pool } = pg;

const app = express();
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
const signingSecret = process.env.MAILWEBHOOK_SIGNING_SECRET;

const claimSql = `
WITH claim AS (
    INSERT INTO webhook_idempotency (
        idempotency_key,
        request_hash,
        status
    )
    VALUES ($1, $2, 'accepted')
    ON CONFLICT (idempotency_key) DO NOTHING
    RETURNING idempotency_key
),
job AS (
    INSERT INTO webhook_jobs (idempotency_key, payload)
    SELECT idempotency_key, $3::jsonb
    FROM claim
    RETURNING id
)
SELECT
    EXISTS (SELECT 1 FROM claim) AS claimed,
    (SELECT id FROM job) AS job_id,
    idem.request_hash,
    idem.status,
    idem.response_status,
    idem.response_body
FROM webhook_idempotency AS idem
WHERE idem.idempotency_key = $1;
`;

/**
 * Parse the MailWebhook signature header into named attributes.
 *
 * @param {string | undefined} header - X-MailWebhook-Signature value.
 * @returns {object} Parsed signature fields with t, kid, and v1.
 * @throws {Error} When the header is missing or incomplete.
 */
function parseSignatureHeader(header) {
  if (typeof header !== "string" || header.trim() === "") {
    throw new Error("missing signature header");
  }

  const parts = new Map();
  for (const item of header.split(",")) {
    const [rawKey, ...rawValue] = item.trim().split("=");
    const key = rawKey?.trim();
    const value = rawValue.join("=").trim();
    if (key && value) {
      parts.set(key, value);
    }
  }

  const t = parts.get("t");
  const kid = parts.get("kid");
  const v1 = parts.get("v1");
  if (!t || !kid || !v1) {
    throw new Error("incomplete signature header");
  }

  return { t, kid, v1 };
}

/**
 * Verify a MailWebhook HMAC signature against the raw request body.
 *
 * @param {Buffer} rawBody - Exact HTTP request body bytes.
 * @param {string | undefined} signatureHeader - X-MailWebhook-Signature value.
 * @param {string | undefined} secret - Signing secret for the header kid.
 * @returns {boolean} True when the signature matches.
 */
function verifyMailWebhookSignature(rawBody, signatureHeader, secret) {
  if (!Buffer.isBuffer(rawBody)) {
    return false;
  }
  if (typeof secret !== "string" || secret.length === 0) {
    return false;
  }

  const { t, v1 } = parseSignatureHeader(signatureHeader);
  const signedPayload = Buffer.concat([
    Buffer.from(`${t}.`, "utf8"),
    rawBody,
  ]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest();
  const received = Buffer.from(v1, "base64");

  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(received, expected)
  );
}

/**
 * Return a stable SHA-256 fingerprint for the raw request body.
 *
 * @param {Buffer} rawBody - Exact HTTP request body bytes.
 * @returns {string} Hex-encoded SHA-256 digest.
 */
function requestHash(rawBody) {
  return crypto.createHash("sha256").update(rawBody).digest("hex");
}

/**
 * Atomically claim a webhook idempotency key and enqueue new work.
 *
 * @param {import("pg").PoolClient} client - PostgreSQL client.
 * @param {string} idempotencyKey - X-Idempotency-Key header value.
 * @param {string} hash - SHA-256 request fingerprint.
 * @param {unknown} payload - Parsed webhook JSON payload.
 * @returns {Promise<object>} Claim result row.
 */
async function claimWebhookDelivery(client, idempotencyKey, hash, payload) {
  if (typeof idempotencyKey !== "string" || idempotencyKey.trim() === "") {
    throw new Error("idempotency key is required");
  }
  if (!/^[a-f0-9]{64}$/.test(hash)) {
    throw new Error("request hash must be a SHA-256 hex digest");
  }

  const result = await client.query(claimSql, [
    idempotencyKey,
    hash,
    JSON.stringify(payload),
  ]);
  if (result.rowCount !== 1) {
    throw new Error("idempotency claim returned no row");
  }

  return result.rows[0];
}

app.post(
  "/webhooks/mailwebhook",
  express.raw({ type: "application/json", limit: "2mb" }),
  async (req, res) => {
    const signatureHeader = req.header("x-mailwebhook-signature");
    const idempotencyKey = req.header("x-idempotency-key");

    if (!idempotencyKey) {
      return res.status(400).json({ error: "missing idempotency key" });
    }

    let verified = false;
    try {
      verified = verifyMailWebhookSignature(
        req.body,
        signatureHeader,
        signingSecret,
      );
    } catch {
      verified = false;
    }
    if (!verified) {
      return res.status(401).json({ error: "invalid signature" });
    }

    let payload;
    try {
      payload = JSON.parse(req.body.toString("utf8"));
    } catch {
      return res.status(400).json({ error: "invalid json" });
    }

    const hash = requestHash(req.body);
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      const claim = await claimWebhookDelivery(
        client,
        idempotencyKey,
        hash,
        payload,
      );
      await client.query("COMMIT");

      if (claim.request_hash !== hash) {
        return res
          .status(409)
          .json({ error: "idempotency key reused with a different payload" });
      }

      return res
        .status(claim.response_status)
        .json(claim.response_body);
    } catch (error) {
      await client.query("ROLLBACK");
      console.error("webhook receiver failed", error);
      return res.status(503).json({ error: "receiver unavailable" });
    } finally {
      client.release();
    }
  },
);

The endpoint returns 202 for the first accepted delivery and also for safe duplicates while the job is queued. If a worker later records a different response in webhook_idempotency, future duplicates can return that stored result.

This FastAPI example follows the same contract:

  • Read await request.body() before JSON parsing.
  • Verify the HMAC with hmac.compare_digest(). (Python hmac documentation)
  • Use the same PostgreSQL claim SQL through psycopg.
  • Validate the accepted response shape with Pydantic.
import base64
import hashlib
import hmac
import json
import os
from typing import Any

from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from pydantic import BaseModel, Field


app = FastAPI()
pool = ConnectionPool(os.environ["DATABASE_URL"])
SIGNING_SECRET = os.environ["MAILWEBHOOK_SIGNING_SECRET"]

CLAIM_SQL = """
WITH claim AS (
    INSERT INTO webhook_idempotency (
        idempotency_key,
        request_hash,
        status
    )
    VALUES (%s, %s, 'accepted')
    ON CONFLICT (idempotency_key) DO NOTHING
    RETURNING idempotency_key
),
job AS (
    INSERT INTO webhook_jobs (idempotency_key, payload)
    SELECT idempotency_key, %s::jsonb
    FROM claim
    RETURNING id
)
SELECT
    EXISTS (SELECT 1 FROM claim) AS claimed,
    (SELECT id FROM job) AS job_id,
    idem.request_hash,
    idem.status,
    idem.response_status,
    idem.response_body
FROM webhook_idempotency AS idem
WHERE idem.idempotency_key = %s;
"""


class AcceptedResponse(BaseModel):
    """Response body returned after durable webhook acceptance."""

    status: str = Field(default="accepted", min_length=1)


def parse_signature_header(header: str | None) -> dict[str, str]:
    """Parse X-MailWebhook-Signature into named fields.

    Args:
        header: Raw signature header value.

    Returns:
        Mapping with t, kid, and v1 fields.

    Raises:
        ValueError: If the header is missing or incomplete.
    """
    if not isinstance(header, str) or not header.strip():
        raise ValueError("missing signature header")

    parts: dict[str, str] = {}
    for item in header.split(","):
        key, separator, value = item.strip().partition("=")
        if separator and key and value:
            parts[key] = value

    if not all(parts.get(name) for name in ("t", "kid", "v1")):
        raise ValueError("incomplete signature header")
    return parts


def verify_mailwebhook_signature(
    raw_body: bytes,
    signature_header: str | None,
    secret: str,
) -> bool:
    """Verify a MailWebhook HMAC signature against raw body bytes.

    Args:
        raw_body: Exact HTTP request body bytes.
        signature_header: X-MailWebhook-Signature value.
        secret: Signing secret for the header kid.

    Returns:
        True when the HMAC digest matches.
    """
    if not raw_body or not isinstance(secret, str) or not secret:
        return False

    fields = parse_signature_header(signature_header)
    signed_payload = fields["t"].encode("utf-8") + b"." + raw_body
    expected = hmac.new(
        secret.encode("utf-8"),
        signed_payload,
        hashlib.sha256,
    ).digest()

    try:
        received = base64.b64decode(fields["v1"], validate=True)
    except ValueError:
        return False

    return hmac.compare_digest(received, expected)


def request_hash(raw_body: bytes) -> str:
    """Return a SHA-256 fingerprint for raw request bytes.

    Args:
        raw_body: Exact HTTP request body bytes.

    Returns:
        Hex-encoded SHA-256 digest.
    """
    return hashlib.sha256(raw_body).hexdigest()


def claim_webhook_delivery(
    idempotency_key: str,
    hash_value: str,
    payload: dict[str, Any],
) -> dict[str, Any]:
    """Claim one webhook delivery and enqueue work in PostgreSQL.

    Args:
        idempotency_key: X-Idempotency-Key header value.
        hash_value: SHA-256 request fingerprint.
        payload: Parsed JSON webhook payload.

    Returns:
        PostgreSQL row describing the claim or existing idempotency record.

    Raises:
        ValueError: If required inputs are malformed.
        RuntimeError: If PostgreSQL does not return a claim row.
    """
    if not isinstance(idempotency_key, str) or not idempotency_key.strip():
        raise ValueError("idempotency key is required")
    if len(hash_value) != 64 or any(c not in "0123456789abcdef" for c in hash_value):
        raise ValueError("request hash must be a SHA-256 hex digest")
    if not isinstance(payload, dict):
        raise ValueError("payload must be a JSON object")

    with pool.connection() as conn:
        with conn.transaction():
            with conn.cursor(row_factory=dict_row) as cur:
                cur.execute(
                    CLAIM_SQL,
                    (
                        idempotency_key,
                        hash_value,
                        json.dumps(payload, separators=(",", ":")),
                        idempotency_key,
                    ),
                )
                row = cur.fetchone()

    if row is None:
        raise RuntimeError("idempotency claim returned no row")
    return dict(row)


@app.post("/webhooks/mailwebhook")
async def receive_mailwebhook(
    request: Request,
    x_mailwebhook_signature: str | None = Header(default=None),
    x_idempotency_key: str | None = Header(default=None),
) -> JSONResponse:
    """Accept a signed MailWebhook delivery through a PostgreSQL gate."""
    if not x_idempotency_key:
        raise HTTPException(status_code=400, detail="missing idempotency key")

    raw_body = await request.body()
    try:
        verified = verify_mailwebhook_signature(
            raw_body,
            x_mailwebhook_signature,
            SIGNING_SECRET,
        )
    except ValueError:
        verified = False
    if not verified:
        raise HTTPException(status_code=401, detail="invalid signature")

    try:
        payload = json.loads(raw_body.decode("utf-8"))
    except json.JSONDecodeError as exc:
        raise HTTPException(status_code=400, detail="invalid json") from exc
    if not isinstance(payload, dict):
        raise HTTPException(status_code=400, detail="payload must be an object")

    hash_value = request_hash(raw_body)
    try:
        claim = claim_webhook_delivery(x_idempotency_key, hash_value, payload)
    except Exception as exc:
        raise HTTPException(status_code=503, detail="receiver unavailable") from exc

    if claim["request_hash"] != hash_value:
        raise HTTPException(
            status_code=409,
            detail="idempotency key reused with a different payload",
        )

    response_body = claim["response_body"] or AcceptedResponse().model_dump()
    return JSONResponse(
        status_code=int(claim["response_status"]),
        content=response_body,
    )

FastAPI lets you return JSONResponse when the stored idempotency record controls the status code. That keeps duplicate responses explicit instead of relying on framework defaults.

This Go example uses the standard net/http package, crypto/hmac, and pgxpool. Go’s HMAC package documents hmac.Equal() for MAC comparison, which is the right helper for webhook signature checks. (Go hmac documentation)

The handler reads a bounded raw body, verifies the signature, validates that the JSON body is an object, claims the idempotency key in PostgreSQL, and returns the stored response.

package webhooks

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"mime"
	"net/http"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

const claimSQL = `
WITH claim AS (
    INSERT INTO webhook_idempotency (
        idempotency_key,
        request_hash,
        status
    )
    VALUES ($1, $2, 'accepted')
    ON CONFLICT (idempotency_key) DO NOTHING
    RETURNING idempotency_key
),
job AS (
    INSERT INTO webhook_jobs (idempotency_key, payload)
    SELECT idempotency_key, $3::jsonb
    FROM claim
    RETURNING id
)
SELECT
    EXISTS (SELECT 1 FROM claim) AS claimed,
    COALESCE((SELECT id FROM job), 0) AS job_id,
    idem.request_hash,
    idem.status,
    idem.response_status,
    idem.response_body
FROM webhook_idempotency AS idem
WHERE idem.idempotency_key = $4;
`

type signatureParts struct {
	timestamp int64
	kid       string
	macBase64 string
}

type claimResult struct {
	claimed        bool
	jobID          int64
	requestHash    string
	status         string
	responseStatus int
	responseBody   []byte
}

// parseSignatureHeader parses X-MailWebhook-Signature into named fields.
func parseSignatureHeader(signatureHeader string) (signatureParts, bool) {
	parts := make(map[string]string)
	for _, item := range strings.Split(signatureHeader, ",") {
		key, value, ok := strings.Cut(strings.TrimSpace(item), "=")
		if !ok || key == "" || value == "" {
			continue
		}
		parts[key] = value
	}

	timestamp, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || parts["kid"] == "" || parts["v1"] == "" {
		return signatureParts{}, false
	}

	return signatureParts{
		timestamp: timestamp,
		kid:       parts["kid"],
		macBase64: parts["v1"],
	}, true
}

// verifyMailWebhookSignature verifies an HMAC signature against raw body bytes.
func verifyMailWebhookSignature(
	rawBody []byte,
	signatureHeader string,
	signingSecret []byte,
	tolerance time.Duration,
) bool {
	if len(rawBody) == 0 || len(signingSecret) == 0 {
		return false
	}

	parts, ok := parseSignatureHeader(signatureHeader)
	if !ok {
		return false
	}

	signedAt := time.Unix(parts.timestamp, 0)
	now := time.Now()
	if signedAt.Before(now.Add(-tolerance)) || signedAt.After(now.Add(tolerance)) {
		return false
	}

	received, err := base64.StdEncoding.DecodeString(parts.macBase64)
	if err != nil {
		return false
	}

	mac := hmac.New(sha256.New, signingSecret)
	mac.Write([]byte(strconv.FormatInt(parts.timestamp, 10)))
	mac.Write([]byte("."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)

	return hmac.Equal(received, expected)
}

// requestHash returns a SHA-256 fingerprint for raw request bytes.
func requestHash(rawBody []byte) string {
	sum := sha256.Sum256(rawBody)
	return hex.EncodeToString(sum[:])
}

// validSHA256Hex reports whether value is a lowercase SHA-256 hex digest.
func validSHA256Hex(value string) bool {
	if len(value) != 64 {
		return false
	}
	for _, char := range value {
		if !strings.ContainsRune("0123456789abcdef", char) {
			return false
		}
	}
	return true
}

// claimWebhookDelivery claims a delivery key and queues first-seen work.
func claimWebhookDelivery(
	ctx context.Context,
	pool *pgxpool.Pool,
	idempotencyKey string,
	hashValue string,
	rawBody []byte,
) (claimResult, error) {
	if strings.TrimSpace(idempotencyKey) == "" {
		return claimResult{}, errors.New("idempotency key is required")
	}
	if !validSHA256Hex(hashValue) {
		return claimResult{}, errors.New("request hash must be a SHA-256 hex digest")
	}

	var result claimResult
	err := pool.QueryRow(
		ctx,
		claimSQL,
		idempotencyKey,
		hashValue,
		string(rawBody),
		idempotencyKey,
	).Scan(
		&result.claimed,
		&result.jobID,
		&result.requestHash,
		&result.status,
		&result.responseStatus,
		&result.responseBody,
	)
	if err != nil {
		return claimResult{}, err
	}

	return result, nil
}

// MailWebhookHandler accepts signed webhooks through a PostgreSQL idempotency gate.
func MailWebhookHandler(pool *pgxpool.Pool, signingSecret []byte) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost {
			http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
			return
		}

		mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
		if err != nil || mediaType != "application/json" {
			http.Error(w, "expected application/json", http.StatusUnsupportedMediaType)
			return
		}

		rawBody, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 2<<20))
		if err != nil {
			http.Error(w, "request body too large", http.StatusRequestEntityTooLarge)
			return
		}

		if !verifyMailWebhookSignature(
			rawBody,
			r.Header.Get("X-MailWebhook-Signature"),
			signingSecret,
			5*time.Minute,
		) {
			http.Error(w, "invalid signature", http.StatusUnauthorized)
			return
		}

		idempotencyKey := r.Header.Get("X-Idempotency-Key")
		if idempotencyKey == "" {
			http.Error(w, "missing idempotency key", http.StatusBadRequest)
			return
		}

		var payload map[string]any
		if err := json.Unmarshal(rawBody, &payload); err != nil {
			http.Error(w, "payload must be a JSON object", http.StatusBadRequest)
			return
		}

		hashValue := requestHash(rawBody)
		claim, err := claimWebhookDelivery(
			r.Context(),
			pool,
			idempotencyKey,
			hashValue,
			rawBody,
		)
		if err != nil {
			http.Error(w, "receiver unavailable", http.StatusServiceUnavailable)
			return
		}
		if claim.requestHash != hashValue {
			http.Error(
				w,
				"idempotency key reused with a different payload",
				http.StatusConflict,
			)
			return
		}

		responseBody := claim.responseBody
		if len(responseBody) == 0 {
			responseBody = []byte(`{"status":"accepted"}`)
		}

		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(claim.responseStatus)
		_, _ = w.Write(responseBody)
	}
}

The Go version follows the same storage contract as the Node.js and Python versions. PostgreSQL decides whether the delivery is new. The handler decides which HTTP response to return from that durable decision.

What to return on duplicate delivery

Duplicate delivery should be boring. The receiver should not treat it as an incident when the key and request hash match.

A simple response policy works well:

Condition Response
Missing idempotency key 400 Bad Request
Invalid signature 401 Unauthorized
First valid delivery 202 Accepted
Duplicate with same request hash Stored response, often 202
Same key with different request hash 409 Conflict
PostgreSQL unavailable 503 Service Unavailable

The 503 case is important. If the receiver cannot reach the idempotency store, it cannot safely decide whether the event is new or a duplicate. Returning a transient failure lets the sender retry after the storage layer recovers.

Duplicate delivery response policy concept image

Operational checks

Before this goes into production, test it under repeated delivery and concurrency.

Use this checklist:

  • Send one valid webhook and confirm one webhook_idempotency row.
  • Send the same body and key again and confirm no second job is queued.
  • Send the same key with a changed body and confirm 409.
  • Send two identical requests at the same time and confirm one job.
  • Stop PostgreSQL and confirm the receiver returns 503.
  • Break the signature and confirm the idempotency table is untouched.
  • Replay a stored MailWebhook event and confirm the duplicate path.

Those tests prove the behavior that matters: a valid repeated delivery becomes a stable answer, and an unsafe repeat is rejected before business state changes.

Where MailWebhook fits

MailWebhook already sends the delivery fields this pattern needs:

Your receiver owns the final storage rule. PostgreSQL is a strong fit when the webhook side effect also depends on relational state, because the idempotency claim, queued work, and later business update can share one durable system.

MailWebhook receiver storage boundary concept image

The core rule is simple: verify authenticity first, claim the idempotency key second, and let every side effect depend on that claim. With that sequence in place, repeated webhook delivery becomes a normal reliability condition instead of a duplicate-action bug.