DEV Community

JudsonRhodes1569
JudsonRhodes1569

Posted on

Media Reservation Expiry: Public HTTPS Webhook Endpoint Signature and Ack State

Short answer: accept a queue push only after the public HTTPS endpoint has verified the webhook signature, claimed the delivery ID, and committed the reservation's state transition; then return a success ack. If any of those steps can't finish, don't acknowledge the delivery. Let the sender retry while the subscriber remains idempotent.

This ordering is the delivery guarantee. The timer is almost the easy part.

Imagine a media service that holds a seat for an advance screening for 15 minutes. A delayed task becomes eligible at expiresAt, then a push subscriber receives it. The useful promise isn't “the callback ran once.” The promise is narrower and testable: after a successful acknowledgement, that delivery ID is recorded and a reservation that was still held has become expired. A reservation already paid for remains paid for.

Why reservation expiry makes ack timing hard

The before model is tempting: receive JSON, return 204, and expire the reservation in the background. It is quick. It is also a promise made too early. A process exit after the response can leave a stale hold, while the queue has already been told that the delivery succeeded.

The after model moves the ack across the commit boundary. In words, the diagram is: delayed task becomes eligible -> publisher signs the exact request bytes -> public endpoint verifies them -> database transaction claims the delivery -> conditional reservation update runs -> transaction commits -> endpoint returns 204. A retry follows the same path, sees the claimed delivery ID, and returns the same successful acknowledgement without applying a second transition.

One detail matters: scheduled time and execution time aren't identical concepts. The hold's expiresAt value is business state, so the update should check it rather than trusting arrival time alone. GitHub's schedule documentation, for example, warns that scheduled workflows can be delayed during periods of high load. AWS documents deduplication and ordering behavior for FIFO queues, but a queue's transport features still don't replace the subscriber's domain-state condition. Different tools expose different knobs; the reservation rule belongs in your transaction.

Tiny distinction. Big effect.

How should a public HTTPS queue push webhook endpoint verify signatures and ack?

First, define one signed-message contract with the publisher. The example below uses HMAC-SHA-256 over <timestamp>.<raw request body>, hexadecimal signatures, a unique delivery ID, and a five-minute replay window. Those are example contract choices, not universal webhook conventions. If your publisher specifies a different canonical string, encoding, key scheme, or timestamp rule, implement that specification exactly.

Verify before parsing JSON. JSON parsing and reserialization can change insignificant whitespace or key order, which changes the bytes covered by a signature. Also compare equal-length signature buffers with a timing-safe comparison. A malformed signature should be rejected without entering the reservation transaction.

The acknowledgement rule is just as crisp:

Subscriber outcome State committed? Response in this contract Sender action
Valid, first delivery Yes 204 Stop retrying
Valid duplicate Already committed 204 Stop retrying
Invalid signature or stale timestamp No 401 Treat as rejected
Temporary storage failure No 503 Retry later

Don't collapse authentication failure and dependency failure into the same response. They mean different things operationally, and retrying a request that can never authenticate only adds noise.

A copyable TypeScript subscriber

This Node.js example uses the built-in HTTP and crypto modules. The storage port deliberately expresses one atomic operation: claim the delivery and conditionally expire the hold. Back it with a transaction that has a unique constraint on deliveryId; the reservation update should require status = "held" and expiresAt <= event.expiresAt. That means a payment transition that won the race is preserved, and a duplicate delivery is harmless.

import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer, IncomingMessage, ServerResponse } from "node:http";

type ExpiryEvent = {
  type: "reservation.expire";
  reservationId: string;
  titleId: string;
  expiresAt: string;
};

type CommitResult = "expired" | "already-processed" | "no-longer-held";

interface ReservationStore {
  claimAndExpire(deliveryId: string, event: ExpiryEvent): Promise<CommitResult>;
}

const secret = process.env.WEBHOOK_SECRET;
if (!secret) throw new Error("WEBHOOK_SECRET is required");

const MAX_BODY_BYTES = 64 * 1024;
const MAX_AGE_SECONDS = 300;

async function readBody(req: IncomingMessage): Promise<Buffer> {
  const chunks: Buffer[] = [];
  let size = 0;

  for await (const chunk of req) {
    const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
    size += bytes.length;
    if (size > MAX_BODY_BYTES) throw new Error("body-too-large");
    chunks.push(bytes);
  }

  return Buffer.concat(chunks);
}

function verifySignature(raw: Buffer, timestamp: string, supplied: string): boolean {
  if (!/^\d+$/.test(timestamp) || !/^[a-f0-9]{64}$/i.test(supplied)) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > MAX_AGE_SECONDS) return false;

  const expected = createHmac("sha256", secret)
    .update(timestamp)
    .update(".")
    .update(raw)
    .digest();
  const actual = Buffer.from(supplied, "hex");

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

function isExpiryEvent(value: unknown): value is ExpiryEvent {
  if (!value || typeof value !== "object") return false;
  const event = value as Record<string, unknown>;
  return event.type === "reservation.expire"
    && typeof event.reservationId === "string"
    && typeof event.titleId === "string"
    && typeof event.expiresAt === "string"
    && Number.isFinite(Date.parse(event.expiresAt));
}

export function reservationExpiryServer(store: ReservationStore) {
  return createServer(async (req: IncomingMessage, res: ServerResponse) => {
    if (req.method !== "POST" || req.url !== "/webhooks/reservation-expiry") {
      res.writeHead(404).end();
      return;
    }

    const deliveryId = req.headers["x-delivery-id"];
    const timestamp = req.headers["x-delivery-timestamp"];
    const signature = req.headers["x-delivery-signature"];
    if (typeof deliveryId !== "string" || typeof timestamp !== "string"
      || typeof signature !== "string") {
      res.writeHead(401).end();
      return;
    }

    try {
      const raw = await readBody(req);
      if (!verifySignature(raw, timestamp, signature)) {
        res.writeHead(401).end();
        return;
      }

      const candidate: unknown = JSON.parse(raw.toString("utf8"));
      if (!isExpiryEvent(candidate)) {
        res.writeHead(400).end();
        return;
      }

      await store.claimAndExpire(deliveryId, candidate);
      res.writeHead(204).end();
    } catch (error) {
      const status = error instanceof Error && error.message === "body-too-large" ? 413 : 503;
      res.writeHead(status).end();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Keep the delivery claim and state change in one database transaction. A check-then-insert sequence split across calls has a race: two concurrent copies can both observe “not processed” before either writes. The unique constraint is the referee. If its conflict is raised inside the transaction, interpret that conflict as already-processed, load no side effects, commit or roll back as your database requires, and return 204. The HTTP handler doesn't need to know which successful state occurred, but logs and metrics do.

I'm not sure a five-minute replay window is right for every publisher. It depends on maximum delivery delay and clock discipline. Measure those two values, set an explicit tolerance, and alert on clock skew rather than quietly widening the window forever.

What should operators observe before trusting the ack pattern?

Start with structured logs keyed by deliveryId, reservationId, and a low-cardinality outcome. Record signature validation as a boolean or result label, never the secret or supplied signature. The useful outcomes are expired, already_processed, no_longer_held, rejected, and retryable_failure. A log line should make one delivery traceable without turning identifiers into metric labels.

Then graph four signals: request rate, acknowledgement latency, retryable failure rate, and the age of the oldest unexpired reservation past its deadline. The last one is the business backstop. An endpoint can return plenty of fast responses while reservations remain held because of a bad condition or a producer-side scheduling gap. Alerting only on HTTP status misses that failure mode — watch the invariant users care about.

Test the transitions, not just the happy request. Send the same signed payload twice and assert one state change with two 204 responses. Send two deliveries concurrently. Pay the reservation immediately before expiry and assert no-longer-held. Change one raw byte after signing and expect 401. Advance the clock beyond the configured window. Finally, force the store operation to reject and verify that the handler doesn't emit a success ack. These tests turn “at least once” from a slogan into observable behavior.

For deployment, rotate secrets with an overlap period only if the publisher's contract supports identifying or trying active keys. Cap body size before allocation grows without bound. Put TLS termination at a component your team patches and monitors, but preserve the raw entity body through any proxy. And set the subscriber timeout below the publisher's delivery timeout when that value is documented, because an ambiguous disconnect can produce a retry even when local work completed.

When should you not use queue push delivery?

Push is not suitable when the subscriber cannot expose a reachable HTTPS endpoint, when inbound request policy is harder to operate than an outbound worker, or when processing routinely exceeds the publisher's acknowledgement deadline. In those cases, stick with worker polling: it gives the consumer direct control over fetch rate, concurrency, and visibility-extension behavior. The catch is more worker lifecycle and backpressure machinery.

A scheduler alone is also a poor fit when every reservation needs an independently durable deadline and delayed execution can bunch up. A periodic database sweep can be the simpler choice when expiry tolerates a coarse interval: index held reservations by expiresAt, claim batches, and use the same conditional transition. GitHub Actions supports scheduled workflows, but its documentation explicitly says high load can delay scheduled jobs and that some queued jobs may be dropped; that boundary makes it unsuitable as the precise expiry clock for a user-facing reservation.

If strict global ordering is the requirement, study the queue's ordering scope rather than assuming “FIFO” means one total order. AWS documents that messages in the same message group are processed one at a time, while different groups may be processed concurrently. For reservation expiry, grouping by reservation or screening can preserve the order that matters without serializing unrelated titles. Still, the conditional database transition remains necessary. Transport order can't decide whether payment or expiry owns the final state.

The decision rule is plain: choose push when public ingress and short transactions are operationally comfortable; choose polling when consumer-controlled flow matters more; choose a sweep when timing can be coarse and the database is already the source of truth. Keep signature verification at the edge, idempotency at the commit boundary, and the ack after durable state.

References

Further reading

The two primary references above are useful for comparing message-group ordering and deduplication with the timing limitations of scheduled workflow triggers. Read them against your own publisher's signature and acknowledgement contract; those details determine the exact canonical bytes, response codes, and retry deadlines.

Top comments (0)