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
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;
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;
};
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,
};
}
Notice the strict comparison:
now < serviceWindowExpiresAt
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:
- Verify the provider webhook.
- Parse the verified payload.
- Confirm that the event represents a user-sent inbound message.
- Deduplicate using the stable message or event ID.
- Reject stale state changes from older events.
- Set the expiry to the accepted message timestamp plus 24 hours.
- Increment
stateVersion. - 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,
};
}
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:
- Lock the reply job.
- Load the latest conversation state.
- Evaluate the state using the current server time.
- Record the decision and
stateVersion. - Mark the job for the selected route.
- Commit the short transaction.
- 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;
});
}
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");
}
}
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
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);
});
});
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;
};
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:
- Update the window only from verified, deduplicated user messages.
- Store explicit timestamps and a monotonic state version.
- Re-evaluate the state when a queued reply is claimed.
- Check the deadline again before the external API request.
- Block expired free-form replies.
- Route them to an approved-template decision or human review.
- 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.
Top comments (0)