← All posts
Tutorial

Implement a WhatsApp 24-Hour Send Guard for Queued Replies

Implement a WhatsApp 24-hour send guard by storing the latest user-message timestamp, free-entry expiry, and a state version, then rechecking them transactionally when a queued reply is claimed. If the service window has expired, block the non-template send and route the job to an approved-template or human-review path. For message-category and October 2026 billing rules, use the existing decision guide.

Key takeaways

  • Persist the 24-hour expiry, the separate free-entry expiry, and a monotonic state version.
  • Update the window only from a verified, deduplicated inbound user message.
  • Reload the state inside the same lock or transaction that claims the queued reply.
  • Treat an expired draft as a routing decision; never send it automatically or silently rewrite it as a template.
  • Test duplicates, out-of-order events, concurrent workers, and the exact expiry second.

Build a WhatsApp send guard around queued replies

The guard enforces one narrow rule: WhatsApp’s official Business Platform pricing page says a user message opens a 24-hour customer service window and every later user message resets it. That timestamp controls whether a non-template reply is still permitted when a worker actually sends it.

This article deliberately does not repeat the full category and pricing decision. Meta’s October 2026 pricing update changes the cost of an allowed delivery, not how a queued worker should protect the permission boundary. Use the linked classification guide for service, utility, Meta Business Agent, and free-entry charging rules; use this implementation for the queue race that remains afterward.

When a worker claims a reply, it should answer three implementation questions:

  1. Is this the latest state version? A newer user message or another worker may have changed the conversation.
  2. Is the service window still open at send time? Approval time and send time are not equivalent.
  3. Is this job still authorized for its selected path? An expired free-form draft must stop before transport.

Do not compress those questions into one isFreeConversation boolean. A queue needs explicit timestamps and versioned state to make the final decision safely.

Model two clocks, not one status

Use explicit timestamps so that a retry, delayed job, or support-agent handoff can be evaluated again.

Stored valueWhat starts or resets itWhat it controls
service_window_expires_atEvery inbound user messageWhether a non-template service reply is allowed
free_entry_expires_atAn eligible user entry from a Click-to-WhatsApp ad or Facebook Page call-to-action buttonWhether message delivery is exempt for the 72-hour period
last_user_message_idEvery accepted inbound user messageIdempotency and audit evidence for the current window
state_versionEvery state-changing inbound eventPreventing a queued sender from writing over newer state

The 72-hour free entry point is not an extra-long customer service window. Preserve both clocks. A user message inside the 72-hour period can reset the 24-hour permission clock, while the original free-entry expiry remains its own boundary.

Implement the send guard

The following TypeScript is an application-level example, not a Meta payload schema. It deliberately returns permission and expected charging separately:

type WindowState = {
  serviceWindowExpiresAt: Date | null;
  freeEntryExpiresAt: Date | null;
};

function evaluateWhatsAppSend(state: WindowState, now: Date) {
  const serviceWindowOpen =
    state.serviceWindowExpiresAt !== null && now < state.serviceWindowExpiresAt;
  const freeEntryActive =
    state.freeEntryExpiresAt !== null && now < state.freeEntryExpiresAt;

  return {
    maySendNonTemplate: serviceWindowOpen,
    expectedDeliveryCharge: serviceWindowOpen && !freeEntryActive,
    requiredPath: serviceWindowOpen ? "service" : "approved_template",
  } as const;
}

Use the result as a final send guard, not only as a UI hint. A reply drafted at 10:00 can sit in an approval queue until after the window closes. The sender should reload the current state inside the same transaction or lock used to claim the job, then choose one of three outcomes:

  1. Send the non-template service reply while the 24-hour window is still open.
  2. Switch to an approved template when the window is closed and the business intent fits that template.
  3. Stop and return the conversation to a human when neither path is valid.

Do not silently convert free-form text into a template or truncate it to fit one. Template category, variables, and approval are separate contracts.

Process inbound events without extending the wrong window

Only a message from the user should reset the customer service window. Delivery receipts, message status changes, agent drafts, internal notes, retries, and business-sent echoes must not extend it.

An implementation checklist:

  1. Verify the provider webhook before changing state.
  2. Deduplicate the inbound message using its stable message or event identifier.
  3. Confirm that the event is a user-sent inbound message, not a receipt or echo.
  4. Set service_window_expires_at to the accepted user-message time plus 24 hours.
  5. Preserve the eligible referral context separately when it establishes a 72-hour free entry point.
  6. Increment state_version and persist the source event for audit.
  7. Re-evaluate both clocks immediately before every send attempt.

Avoid adding 24 hours to the previous expiry. If a user writes at 09:00 and again at 12:00, the new expiry is 12:00 the next day—not the old expiry plus another day.

Test queued-reply and event-ordering boundaries

Run acceptance tests with a fixed clock so the one-second edges are reproducible.

ScenarioExpected result
User message at 10:00; reply at 09:59:59 next dayNon-template service reply allowed
Same message; reply at exactly 10:00 next dayTreat the 24-hour window as closed
New user message at 09:50 next dayReset expiry to 09:50 on the following day
Queued reply approved before expiry but claimed after expiryBlock non-template send and re-route
Duplicate delivery of the same inbound messageKeep the expiry and state_version unchanged
Older inbound event arrives after a newer user messageDo not move the expiry backward
Two workers claim the same state versionLet only one commit; the other must reload and re-evaluate

After launch, reconcile your expected result with the official status and pricing records. The service-message charge tracking guide explains how to keep the delivered-message count separate from the market rate. If you are deciding between Meta Business Agent and your own pipeline, use the Meta Business Agent pricing comparison instead of adding another flag to this state machine.

Where UnifyPort fits

The state machine above applies to the official WhatsApp Business Platform. UnifyPort’s unofficial interface is a separate path for ordinary messaging accounts and does not expose Meta’s customer-service-window or Pricing Analytics state.

On that separate path, an inbound WhatsApp message arrives as a normalized message.received event. When the webhook endpoint has a signing_secret, verify X-Device-Timestamp and X-Device-Signature against the raw request body before processing it. Standard supported replies use POST /v1/messages. These contracts are documented in the webhook delivery and signature reference and the message.received event reference.

Do not feed a UnifyPort event into the official Cloud API billing state machine and claim that Meta classified it. If a team operates both paths, store an explicit transport or control_plane field and keep the ledgers separate.

Limitations and trade-offs

Use the official WhatsApp Business Platform when you need approved templates, campaign tooling, Click-to-WhatsApp attribution, Meta-native analytics, or a supported Business Solution Provider workflow. The official platform is also the source of truth for whether an official delivery was charged.

An unofficial interface cannot approve a template, extend a Meta window, provide Meta Pricing Analytics, or change WhatsApp policy. Its value is a different integration path for ordinary messaging accounts and normalized multi-platform events. Neither path removes the need for server-side idempotency, concurrency control, and audit logs.

Rates and product rules remain time-sensitive. Recheck Meta’s official documentation before the October 1 cutover and do not hard-code a planning rate into the state transition logic.

FAQ

Which timestamp should open or reset the send window?

Use the provider-reported time of the verified inbound user message, normalized to UTC. Do not use the time your queue worker started, an agent opened the conversation, or a delivery receipt arrived.

How should out-of-order webhooks update the state?

Compare the inbound message time with the state already stored. An older event may be recorded for audit, but it must not replace a newer last_user_message_id or move service_window_expires_at backward.

What should happen when a queued reply expires before sending?

Block the non-template transport and return an explicit outcome such as window_expired. The application can then request human review or select an already approved template whose purpose and variables match the message.

Should the system automatically convert an expired draft into a template?

No. Template category, approved wording, variables, and business purpose are separate contracts. Silent conversion can send the wrong content under the wrong approval.

How do duplicate events avoid extending the window twice?

Deduplicate on the stable inbound message or event identifier before changing state. A duplicate may be acknowledged and logged, but it must not increment state_version or recalculate the expiry.

Next step

Implement the inbound boundary first: review the webhook delivery and signature reference, then test that duplicate or out-of-order inbound events cannot incorrectly extend your send window. Use the classification guide only after the state transition is reliable.

Sources

Official sources checked on July 27, 2026: