Skip to main content
API DevelopmentWebhooksPaymentsBackend

API Integration Patterns We Use in Production

ZsTechLabs Team·March 5, 2026·5 min read

Most articles about API integration patterns talk in the abstract — "verify signatures," "handle retries," "be idempotent." Good advice, but it's easier to actually apply when you see the real code behind it. This post walks through patterns we use in production for a real payments integration (Paddle, for WordPress plugin licensing), because the details matter more than the platitudes.

Verify webhook signatures properly, not just "check a header exists"

Every payment provider sends webhooks, and every webhook endpoint needs to verify the request actually came from the provider — not from anyone who found the URL. The naive version of this is checking for the presence of a signature header. The correct version does three things:

  1. Recomputes the expected signature using an HMAC of the raw request body and a shared secret, then compares it to the signature the provider sent.
  2. Uses a timing-safe comparison, not a plain string equality check — a naive === comparison leaks timing information that can theoretically be used to guess the correct signature byte by byte.
  3. Checks the timestamp is recent (a tolerance window, typically a few minutes), so a captured, valid signature from months ago can't be replayed later.
function verifyWebhookSignature(params: {
  rawBody: string;
  signatureHeader: string | null;
  secret: string;
  toleranceSeconds?: number;
}): boolean {
  if (!params.signatureHeader) return false;

  // Parse "ts=...;h1=..." style header into { ts, h1 } segments
  const segments = parseSignatureHeader(params.signatureHeader);
  const timestamp = segments.ts?.[0];
  const signatures = segments.h1 || [];
  if (!timestamp || signatures.length === 0) return false;

  const toleranceSeconds = params.toleranceSeconds ?? 300;
  const nowInSeconds = Math.floor(Date.now() / 1000);
  const signedAt = Number(timestamp);
  if (!Number.isFinite(signedAt) || Math.abs(nowInSeconds - signedAt) > toleranceSeconds) {
    return false; // too old, reject
  }

  const expected = createHmac("sha256", params.secret)
    .update(`${timestamp}:${params.rawBody}`)
    .digest("hex");

  return signatures.some((signature) =>
    timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
  );
}

The critical detail most implementations get wrong: you must verify against the raw request body string, not a re-serialized version of the parsed JSON. If you parse the body to JSON and then re-stringify it to check the signature, differences in key ordering or whitespace will break verification in ways that are maddening to debug. Read the raw body first, verify it, then parse it.

Make webhook processing idempotent, always

Payment providers retry webhooks. Networks drop responses even when your server successfully processed the event. This means your webhook handler will receive the same event more than once, and it needs to handle that gracefully rather than double-processing (imagine issuing two licenses, or sending two "payment received" emails, for one purchase).

The pattern: track processed event IDs, and check before doing any real work.

export async function POST(req: Request) {
  const event = await parseAndVerifyWebhook(req);

  if (await hasProcessedEvent(event.event_id)) {
    return NextResponse.json({ ok: true, deduped: true });
  }

  // ... do the actual provisioning work ...

  await markEventProcessed(event.event_id, event.event_type);
  return NextResponse.json({ ok: true });
}

A subtlety worth calling out: dedupe on the event ID, not the transaction ID or customer ID. A single transaction might legitimately need to be processed more than once in edge cases (for example, a bundle purchase that provisions several related records) — what you actually want to prevent is redoing work for an event you've already handled, not for a business entity you've already seen.

Resolve configuration with a clear fallback order, not a single lookup

Real pricing setups aren't flat. You often need: an exact override for a specific product, a fallback for a whole category of products, and a final default. Rather than scattering conditional logic everywhere a price is looked up, centralize the resolution order into one function:

function getPriceEnvKeys(productSlug: string, planSlug: string, category?: string): string[] {
  return [
    `PRICE_${productSlug}_${planSlug}`,           // exact product override
    category ? `PRICE_CATEGORY_${category}_${planSlug}` : null,  // category fallback
    `PRICE_DEFAULT_${planSlug}`,                   // global default
  ].filter(Boolean);
}

function getPriceId(productSlug: string, planSlug: string, category?: string): string | null {
  for (const key of getPriceEnvKeys(productSlug, planSlug, category)) {
    if (process.env[key]) return process.env[key];
  }
  return null;
}

This does two things well: it lets you launch with sensible defaults and override specific products later without touching code, and — because getPriceEnvKeys is a pure function separate from the lookup — you can surface the exact list of env vars a deployment is missing in an error message, which turns "checkout is broken" into "set PRICE_DEFAULT_SINGLE and it'll work."

Rate-limit the endpoints that authenticate by a shared secret

Any endpoint that checks "does this key + email match a record" (a license lookup, an API key validation) is a target for brute-force guessing, even if each individual guess is cheap. A simple in-memory fixed-window limiter per client IP is often enough for a single-server deployment:

const windows = new Map<string, { count: number; resetAt: number }>();

function checkRateLimit(key: string, limit: number, windowMs: number) {
  const now = Date.now();
  const entry = windows.get(key);
  if (!entry || entry.resetAt <= now) {
    windows.set(key, { count: 1, resetAt: now + windowMs });
    return { allowed: true };
  }
  entry.count += 1;
  return { allowed: entry.count <= limit };
}

Pair this with structured logging of repeated failures from the same IP (not just the rate-limit rejection itself) — that's often the earlier, more useful signal that something is being probed, before it ever hits the hard limit.

The theme underneath all of this

Every pattern above is really the same idea applied in a different place: assume the request could be malicious, duplicated, or incomplete, and design for that case explicitly rather than trusting the happy path. It's a small amount of extra code at integration time, and it's the difference between a payments integration that quietly self-heals from network blips and one that pages you at 2am because a customer got double-charged or double-licensed.

If you're building or hardening a payments or webhook integration and want a second set of eyes on it, get in touch — this is exactly the kind of work we do.