How to Stop Queued WhatsApp Replies After the 24-Hour Window Expires

작성자

카테고리:

← 피드로
DEV Community · unifyport · 2026-07-27 개발(SW)

unifyport

A WhatsApp reply can be valid when an agent writes it and invalid when a queue worker finally sends it.

Approval queues, retries, outages, and delayed jobs can all push a free-form reply beyond WhatsApp’s 24-hour customer service window. Checking the window only in the UI is therefore not enough—the sender needs a server-side guard immediately before transport.

This tutorial builds that guard with explicit timestamps, idempotent inbound processing, and versioned conversation state.

The queue race

Consider this sequence:

10:00 — Customer sends a message
10:05 — Agent writes a reply
10:10 — Reply is approved
09:59 next day — Job is still waiting
10:01 next day — Worker claims the job

Enter fullscreen mode Exit fullscreen mode

The reply was valid when it was written and approved, but the customer service window has expired by the time the worker attempts to send it.

The worker must block the non-template send and return an explicit routing result. It must not trust a decision made earlier in the workflow.

Model timestamps instead of one boolean

Avoid storing a single value such as:

const isFreeConversation = true;

Enter fullscreen mode Exit fullscreen mode

That value does not explain:

  • When the window expires
  • Which user message opened it
  • Whether the event was duplicated
  • Whether a newer event already changed the state
  • Whether a separate free-entry period is active
  • Whether a queued worker is using stale state

Store explicit state instead:

type WhatsAppWindowState = {
  serviceWindowExpiresAt: Date | null;
  freeEntryExpiresAt: Date | null;
  lastUserMessageAt: Date | null;
  lastUserMessageId: string | null;
  stateVersion: number;
};

Enter fullscreen mode Exit fullscreen mode

Each field has a separate job.

Field Purpose serviceWindowExpiresAt Controls whether a non-template service reply is permitted freeEntryExpiresAt Records the separate 72-hour free-entry billing period lastUserMessageAt Prevents an older event from moving the window backward lastUserMessageId Provides idempotency and audit evidence stateVersion Detects stale workers and concurrent state changes

The 72-hour free-entry period is not an extended customer service window. Keep the two clocks separate.

Evaluate permission separately from pricing

The send guard should answer whether the selected transport is permitted.

Do not hard-code market rates or future pricing rules into the permission state machine.

type SendDecision =
  | {
      allowed: true;
      path: "non_template_service";
      serviceWindowExpiresAt: Date;
      stateVersion: number;
      freeEntryActive: boolean;
    }
  | {
      allowed: false;
      path: "approved_template_or_human_review";
      reason: "service_window_expired" | "no_user_message";
      stateVersion: number;
      freeEntryActive: boolean;
    };

function evaluateWhatsAppSend(
  state: WhatsAppWindowState,
  now: Date,
): SendDecision {
  const serviceWindowOpen =
    state.serviceWindowExpiresAt !== null &&
    now.getTime() < state.serviceWindowExpiresAt.getTime();

  const freeEntryActive =
    state.freeEntryExpiresAt !== null &&
    now.getTime() < state.freeEntryExpiresAt.getTime();

  if (!serviceWindowOpen) {
    return {
      allowed: false,
      path: "approved_template_or_human_review",
      reason:
        state.serviceWindowExpiresAt === null
          ? "no_user_message"
          : "service_window_expired",
      stateVersion: state.stateVersion,
      freeEntryActive,
    };
  }

  return {
    allowed: true,
    path: "non_template_service",
    serviceWindowExpiresAt: state.serviceWindowExpiresAt,
    stateVersion: state.stateVersion,
    freeEntryActive,
  };
}

Enter fullscreen mode Exit fullscreen mode

Notice the strict comparison:

now < serviceWindowExpiresAt

Enter fullscreen mode Exit fullscreen mode

At the exact expiry timestamp, treat the window as closed.

Update the window only from verified user messages

Not every webhook should extend the service window.

These events must not reset it:

  • Delivery receipts
  • Read receipts
  • Message status changes
  • Agent drafts
  • Internal notes
  • Retry events
  • Business-sent message echoes
  • Duplicate webhook deliveries

A safe inbound sequence is:

  1. Verify the provider webhook.
  2. Parse the verified payload.
  3. Confirm that the event represents a user-sent inbound message.
  4. Deduplicate using the stable message or event ID.
  5. Reject stale state changes from older events.
  6. Set the expiry to the accepted message timestamp plus 24 hours.
  7. Increment stateVersion.
  8. Save the source event for audit.

A simplified updater:

type InboundUserMessage = {
  id: string;
  occurredAt: Date;
};

function applyInboundUserMessage(
  state: WhatsAppWindowState,
  message: InboundUserMessage,
): WhatsAppWindowState {
  if (message.id === state.lastUserMessageId) {
    return state;
  }

  if (
    state.lastUserMessageAt !== null &&
    message.occurredAt.getTime() <= state.lastUserMessageAt.getTime()
  ) {
    return state;
  }

  return {
    ...state,
    lastUserMessageId: message.id,
    lastUserMessageAt: message.occurredAt,
    serviceWindowExpiresAt: new Date(
      message.occurredAt.getTime() + 24 * 60 * 60 * 1000,
    ),
    stateVersion: state.stateVersion + 1,
  };
}

Enter fullscreen mode Exit fullscreen mode

Do not add 24 hours to the previous expiry.

If the user sends a message at 09:00 and another at 12:00, the new expiry is 12:00 the next day—not one extra day after the previous expiry.

Recheck the state when claiming a queued reply

The UI may display the current window, but the queue worker owns the final decision.

When claiming a job:

  1. Lock the reply job.
  2. Load the latest conversation state.
  3. Evaluate the state using the current server time.
  4. Record the decision and stateVersion.
  5. Mark the job for the selected route.
  6. Commit the short transaction.
  7. Recheck the captured deadline immediately before transport.

Conceptually:

async function claimQueuedReply(
  jobId: string,
  now: Date,
) {
  return database.transaction(async (transaction) => {
    const job = await transaction.lockReplyJob(jobId);
    const state = await transaction.lockConversationState(
      job.conversationId,
    );

    if (job.status !== "queued") {
      return {
        outcome: "already_claimed",
      } as const;
    }

    const decision = evaluateWhatsAppSend(state, now);

    if (!decision.allowed) {
      await transaction.updateReplyJob(jobId, {
        status: "needs_rerouting",
        reason: decision.reason,
        evaluatedStateVersion: decision.stateVersion,
      });

      return {
        outcome: "blocked",
        decision,
      } as const;
    }

    await transaction.updateReplyJob(jobId, {
      status: "ready_to_send",
      evaluatedStateVersion: decision.stateVersion,
      sendBefore: decision.serviceWindowExpiresAt,
    });

    return {
      outcome: "ready",
      decision,
    } as const;
  });
}

Enter fullscreen mode Exit fullscreen mode

Do not keep a database transaction open across the external WhatsApp API request.

Instead, persist the decision and deadline, then let the transport layer perform a final time check before sending:

function assertBeforeTransport(
  sendBefore: Date,
  now: Date,
) {
  if (now.getTime() >= sendBefore.getTime()) {
    throw new Error("service_window_expired");
  }
}

Enter fullscreen mode Exit fullscreen mode

If the job waits again after being marked ready, it must return through the guard.

Do not silently convert expired text into a template

When the 24-hour window closes, the state machine should return a routing decision—not rewrite content.

Safe outcomes include:

non_template_service
approved_template
human_review
blocked

Enter fullscreen mode Exit fullscreen mode

Unsafe behavior includes:

  • Automatically converting arbitrary text into a template
  • Truncating a reply until it fits a template
  • Selecting a template based only on keyword similarity
  • Reusing template variables with a different business purpose
  • Sending first and reviewing the decision afterward

Templates have their own approved wording, category, variables, and business intent. Those contracts belong to a separate decision step.

Test the one-second boundaries

Use a fixed clock in automated tests.

describe("evaluateWhatsAppSend", () => {
  const expiry = new Date("2026-07-28T10:00:00.000Z");

  const state: WhatsAppWindowState = {
    serviceWindowExpiresAt: expiry,
    freeEntryExpiresAt: null,
    lastUserMessageAt: new Date(
      "2026-07-27T10:00:00.000Z",
    ),
    lastUserMessageId: "msg_123",
    stateVersion: 7,
  };

  it("allows a reply one millisecond before expiry", () => {
    const decision = evaluateWhatsAppSend(
      state,
      new Date("2026-07-28T09:59:59.999Z"),
    );

    expect(decision.allowed).toBe(true);
  });

  it("blocks a reply at the exact expiry time", () => {
    const decision = evaluateWhatsAppSend(
      state,
      new Date("2026-07-28T10:00:00.000Z"),
    );

    expect(decision.allowed).toBe(false);
  });
});

Enter fullscreen mode Exit fullscreen mode

Also test these scenarios:

Scenario Expected result Reply at 23:59:59 after the user message Non-template reply allowed Reply at exactly 24 hours Window closed New user message before expiry Expiry resets from the new message Reply approved before expiry but claimed afterward Non-template transport blocked Duplicate inbound webhook State remains unchanged Older event arrives after a newer event Expiry does not move backward Two workers claim the same reply Only one worker commits the claim Job is delayed after being marked ready Transport checks the deadline again

Keep an audit trail

For every send decision, record:

type SendAuditRecord = {
  replyJobId: string;
  conversationId: string;
  evaluatedAt: Date;
  evaluatedStateVersion: number;
  serviceWindowExpiresAt: Date | null;
  freeEntryExpiresAt: Date | null;
  selectedPath:
    | "non_template_service"
    | "approved_template"
    | "human_review"
    | "blocked";
  reason: string;
};

Enter fullscreen mode Exit fullscreen mode

This makes it possible to answer:

  • Which user message opened the window?
  • Which state version did the worker evaluate?
  • Was the job already expired when it was claimed?
  • Did another worker process the same reply?
  • Was the message sent as free-form text or through a template?
  • Why was the job routed to human review?

Implementation checklist

Before release:

  • [ ] Verify webhook signatures before changing state
  • [ ] Deduplicate inbound user messages
  • [ ] Ignore receipts, echoes, and internal events
  • [ ] Normalize provider timestamps to UTC
  • [ ] Store explicit expiry timestamps
  • [ ] Keep the 24-hour and 72-hour clocks separate
  • [ ] Increment a monotonic stateVersion
  • [ ] Re-evaluate state when claiming a queued reply
  • [ ] Check the deadline again immediately before transport
  • [ ] Treat the exact expiry timestamp as closed
  • [ ] Prevent concurrent workers from claiming one reply
  • [ ] Route expired drafts instead of rewriting them
  • [ ] Keep permission and pricing calculations separate
  • [ ] Store a complete send-decision audit record

Final takeaway

A WhatsApp reply is not authorized forever just because it was valid when an agent drafted it.

The reliable boundary is the moment the worker attempts transport.

A safe implementation should:

  1. Update the window only from verified, deduplicated user messages.
  2. Store explicit timestamps and a monotonic state version.
  3. Re-evaluate the state when a queued reply is claimed.
  4. Check the deadline again before the external API request.
  5. Block expired free-form replies.
  6. Route them to an approved-template decision or human review.
  7. Keep charging calculations outside the permission state machine.

The UI can explain the window, but the server-side sender must enforce it.

Official reference

Originally published on UnifyPort.

This article was prepared with AI assistance for language and structure, then technically reviewed and verified by the author.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다