{"id":3162,"date":"2026-11-07T11:48:45","date_gmt":"2026-11-07T06:18:45","guid":{"rendered":"https:\/\/www.smsgatewaycenter.com\/blog\/?p=3162"},"modified":"2026-11-07T12:18:46","modified_gmt":"2026-11-07T06:48:46","slug":"whatsapp-template-buttons-quick-reply-payload-tap-matching","status":"publish","type":"post","link":"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-template-buttons-quick-reply-payload-tap-matching\/","title":{"rendered":"WhatsApp Template Buttons End to End: Quick Reply Payloads, Dynamic URL Buttons and Matching the Tap"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">A quick reply button is a round trip: you define the label in the template, attach a payload on every send, and get a webhook when someone taps. This guide shows how to validate the buttons array, issue signed per-send payloads with buttonsPayload, build dynamic URL buttons with buttonUrlPath, receive taps on the MO webhook, and decide what each tap means without double-counting, misreading typed text or losing an opt-out.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-template-buttons-quick-reply-tap-matching.webp\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"584\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-template-buttons-quick-reply-tap-matching-1024x584.webp\" alt=\"Three button-shaped pills on the left with one teal path running from the middle pill to a hexagon on the right, representing a quick reply tap travelling back to a server.\" class=\"wp-image-3163\" srcset=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-template-buttons-quick-reply-tap-matching-1024x584.webp 1024w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-template-buttons-quick-reply-tap-matching-300x171.webp 300w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-template-buttons-quick-reply-tap-matching-768x438.webp 768w, https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/whatsapp-template-buttons-quick-reply-tap-matching.webp 1200w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><figcaption class=\"wp-element-caption\">A quick reply tap is a message coming back. Route it on the payload you issued, not on the label the person saw.<\/figcaption><\/figure>\n\n\n\n<h1 class=\"wp-block-heading\">Table of Contents<\/h1>\n\n\n\n<ol class=\"wp-block-list\">\n<li><a href=\"#the-short-answer\">The Short Answer<\/a><\/li>\n\n\n\n<li><a href=\"#tldr\">TL;DR<\/a><\/li>\n\n\n\n<li><a href=\"#button-lifecycle\">How a Button Travels: Template, Send, Tap<\/a><\/li>\n\n\n\n<li><a href=\"#endpoints\">Three Endpoints and One Webhook<\/a><\/li>\n\n\n\n<li><a href=\"#field-map\">Where Each Button Field Lives<\/a><\/li>\n\n\n\n<li><a href=\"#validate-buttons\">Validating the Buttons Array Before You Submit<\/a><\/li>\n\n\n\n<li><a href=\"#payload-design\">Designing the Payload: Signed Tokens, Not Labels<\/a><\/li>\n\n\n\n<li><a href=\"#sending\">Sending With buttonsPayload and buttonUrlPath<\/a><\/li>\n\n\n\n<li><a href=\"#mo-webhook\">Receiving the Tap on the MO Webhook<\/a><\/li>\n\n\n\n<li><a href=\"#classify\">Classifying a Tap in a Fixed Order<\/a><\/li>\n\n\n\n<li><a href=\"#python-module\">The Python Module, Tested<\/a><\/li>\n\n\n\n<li><a href=\"#optout-buttons\">Opt-Out Buttons Fail Open<\/a><\/li>\n\n\n\n<li><a href=\"#url-buttons\">Dynamic URL Buttons and Click Tracking<\/a><\/li>\n\n\n\n<li><a href=\"#data-model\">The Tables Behind It<\/a><\/li>\n\n\n\n<li><a href=\"#inbox-poll\">What the Inbox Poll Can and Can&#8217;t Tell You<\/a><\/li>\n\n\n\n<li><a href=\"#service-window\">Buttons and the 24-Hour Window<\/a><\/li>\n\n\n\n<li><a href=\"#ten-mistakes\">Ten Mistakes With WhatsApp Buttons<\/a><\/li>\n\n\n\n<li><a href=\"#decision-matrix\">Decision Matrix: Quick Reply, URL Button or Typed Keyword<\/a><\/li>\n\n\n\n<li><a href=\"#checklist\">The Production Checklist<\/a><\/li>\n\n\n\n<li><a href=\"#unspecified-behaviour\">Unspecified Behaviour and How to Code Around It<\/a><\/li>\n\n\n\n<li><a href=\"#faqs\">FAQs<\/a><\/li>\n<\/ol>\n\n\n\n<h2 id=\"the-short-answer\" class=\"wp-block-heading\">The Short Answer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A WhatsApp quick reply button is a round trip, and each leg is set in a different place. You define the button&#8217;s label when you create the template with <code>WAApi\/template<\/code>. You attach a payload to each button on every send with the <code>buttonsPayload<\/code> field of <code>WAApi\/send<\/code>. When someone taps, WhatsApp sends you a message of type <code>button<\/code> that carries that payload and the label, and you receive it on the WhatsApp MO webhook.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So route on the payload, never on the label. Make each payload a short signed token that points at one send, store every tap once keyed on its WhatsApp message ID, accept only the first answer per send, and let opt-out buttons work even when everything else about the tap looks wrong. URL, phone and copy code buttons never come back to your webhook at all: a URL tap opens the browser, so you measure it at your own redirect.<\/p>\n\n\n\n<h2 id=\"tldr\" class=\"wp-block-heading\">TL;DR<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Labels are fixed at approval, payloads are per send.<\/strong> <code>WAApi\/template<\/code> takes a <code>buttons<\/code> JSON array with <code>text<\/code> and <code>type<\/code> (<code>QUICK_REPLY<\/code>, <code>URL<\/code>, <code>PHONE_NUMBER<\/code>). <code>WAApi\/send<\/code> takes <code>buttonsPayload<\/code> as JSON with keys <code>button1<\/code>, <code>button2<\/code>, <code>button3<\/code>, and the page says &#8220;This value will return in reply.&#8221;<\/li>\n\n\n\n<li><strong>The tap arrives as <code>type: \"button\"<\/code>.<\/strong> Meta&#8217;s reference puts your payload in <code>button.payload<\/code>, the label in <code>button.text<\/code>, and the ID of the message that held the button in <code>context.id<\/code>. The timestamp is a string in seconds.<\/li>\n\n\n\n<li><strong>Validate before you submit.<\/strong> The knowledge base allows up to 3 quick replies and 2 call-to-action buttons with labels up to 20 characters. Meta allows more. Enforce the stricter numbers.<\/li>\n\n\n\n<li><strong>Put quick replies first.<\/strong> Then <code>button1<\/code> means the first quick reply whichever way the platform counts positions.<\/li>\n\n\n\n<li><strong>Sign your payloads.<\/strong> A 41-character token of the form <code>key.ref.sig<\/code> tells you which send and which button, and nobody can produce one by typing.<\/li>\n\n\n\n<li><strong>Store first, decide second.<\/strong> Insert the tap keyed on its <code>wamid<\/code>, answer 200, then classify: replay, opt-out, expired, already answered, accepted, or untracked.<\/li>\n\n\n\n<li><strong>Opt-outs fail open.<\/strong> A Stop tap from an old message, a re-tap, or a template sent without your token still suppresses the number.<\/li>\n\n\n\n<li><strong>Measure URL taps yourself.<\/strong> <code>buttonUrlPath<\/code> fills the variable at the end of a URL button. Put an opaque token there and count the hit on your redirect.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"button-lifecycle\" class=\"wp-block-heading\">How a Button Travels: Template, Send, Tap<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Most write-ups on WhatsApp buttons stop at &#8220;add a Quick Reply in the template builder&#8221;. That&#8217;s the first of three legs, and it&#8217;s the only one that doesn&#8217;t touch your production code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Leg one: the template.<\/strong> You create a template with up to three quick replies or up to two call-to-action buttons (the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/add-interactive-buttons-whatsapp-templates-smsgatewaycenter\/\">buttons knowledge base entry<\/a> lists Quick Reply, Visit Website, Call Phone Number, and Copy Code for authentication templates). Meta reviews the whole template, labels included. The labels, their order and the base URL of any URL button are part of what gets approved. <code>WAApi\/template<\/code> has create, read and delete but no update, so different wording on the API path means a new template.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Leg two: the send.<\/strong> Every time you send that template, you can attach a payload to each quick reply and a suffix to the URL button. This is the only leg where you get to say &#8220;this particular button, on this particular message, means this particular thing&#8221;. If you skip it, every copy of the template is indistinguishable from every other copy once someone taps.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Leg three: the tap.<\/strong> The person taps &#8220;Confirm&#8221;. WhatsApp sends a message back to your business number, of type <code>button<\/code>, carrying the payload you attached and the label they saw. The platform forwards it to your MO webhook. Now your code has to answer a surprisingly hard question: which message was this a reply to, which button was it, and has this person already answered?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rest of the work is making leg three boring. That only works if legs one and two are set up for it.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"endpoints\" class=\"wp-block-heading\">Three Endpoints and One Webhook<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Four surfaces carry button data. Three are REST endpoints on <code>https:\/\/unify.smsgateway.center\/<\/code>, one is a push you configure in the portal.<\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Surface<\/th><th>Method<\/th><th>What it does for buttons<\/th><th>Key fields<\/th><\/tr><\/thead><tbody><tr><td><code>WAApi\/template<\/code><\/td><td>POST<\/td><td>Creates the template, buttons included<\/td><td><code>buttons<\/code> (JSON array), <code>category<\/code>, <code>msgType<\/code>, <code>templateName<\/code>, <code>wabaNumber<\/code><\/td><\/tr><tr><td><code>WAApi\/send<\/code><\/td><td>POST<\/td><td>Sends an approved template and attaches per-send button data<\/td><td><code>templateName<\/code>, <code>buttonsPayload<\/code> (JSON), <code>buttonUrlPath<\/code><\/td><\/tr><tr><td><code>rest\/wa\/v1\/inbox<\/code><\/td><td>GET<\/td><td>Lists inbound messages, newest first<\/td><td><code>messageType<\/code>, <code>messageTypeId<\/code>, <code>message<\/code>, <code>waMsgId<\/code>, <code>timestamp<\/code><\/td><\/tr><tr><td>MO webhook<\/td><td>Push<\/td><td>Delivers each inbound message, taps included, to your URL<\/td><td><code>messages[].type<\/code>, <code>button.payload<\/code>, <code>button.text<\/code>, <code>context.id<\/code>, <code>id<\/code><\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">A few things about this table are easy to miss.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/create-whatsapp-template\/\">create template page<\/a> shows the <code>buttons<\/code> field in Meta&#8217;s own shape: <code>[{\"text\": \"YES\", \"type\": \"QUICK_REPLY\"}]<\/code> for quick replies and <code>[{\"phone_number\": \"...\", \"text\": \"...\", \"type\": \"PHONE_NUMBER\"}, {\"text\": \"...\", \"type\": \"URL\", \"url\": \"https:\/\/...\"}]<\/code> for call to action. There&#8217;s no payload in it. Quick reply payloads aren&#8217;t part of the template at all on the API path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/send-whatsapp-business-message\/\">send page<\/a> is where payloads live: <code>buttonsPayload<\/code> is &#8220;Button Payload in JSON format. This value will return in reply,&#8221; with the example <code>{\"button1\": \"Hello_button1\", \"button2\": \"Hello_button2\", \"button3\": \"Hello_button3\"}<\/code>. <code>buttonUrlPath<\/code> is the &#8220;Dynamic Button URL path&#8221;, example <code>\/photo\/products\/<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The MO webhook isn&#8217;t a REST call you make. You set it up in the portal: Webhooks, Add Webhook, product WhatsApp, report type MO, pick the WABA number, give it your URL, and optionally add custom parameters and custom headers (<a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/configure-webhook-whatsapp-mo-responses\/\">setup steps<\/a>). That custom header is how you&#8217;ll authenticate the platform to your endpoint later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The inbox poll is read-only and accepts the <code>apikey<\/code> header with <code>userid<\/code>. It&#8217;s useful for backfill and support screens, but as you&#8217;ll see in the inbox section, it doesn&#8217;t expose a button payload field.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you want the full WhatsApp endpoint family with its envelopes and error shapes, the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">WhatsApp Business API wire contract<\/a> covers all ten operations.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"field-map\" class=\"wp-block-heading\">Where Each Button Field Lives<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole button surface on one page. The &#8220;Match on&#8221; column is the part people get wrong.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-button-fields-table.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-button-fields-table.svg\" alt=\"Table of six WhatsApp button fields showing where each is defined, whether it changes per send, what arrives on tap, its limit, and what your code should match on.\" class=\"wp-image-3165\"\/><\/a><figcaption class=\"wp-element-caption\">Only quick replies come back to your webhook, and only the payload is yours to design.<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>Field<\/th><th>Defined in<\/th><th>Changes per send?<\/th><th>What reaches you on tap<\/th><th>Limit you should enforce<\/th><th>Route on it?<\/th><\/tr><\/thead><tbody><tr><td>Quick reply label<\/td><td><code>WAApi\/template<\/code> <code>buttons[].text<\/code><\/td><td>No<\/td><td><code>button.text<\/code><\/td><td>3 buttons, 20 characters<\/td><td>No<\/td><\/tr><tr><td>Quick reply payload<\/td><td>Not in the create call<\/td><td>Yes, <code>buttonsPayload<\/code><\/td><td><code>button.payload<\/code><\/td><td>Keep it short and ASCII<\/td><td>Yes<\/td><\/tr><tr><td>URL button<\/td><td><code>WAApi\/template<\/code> <code>buttons[].url<\/code><\/td><td>Suffix only, <code>buttonUrlPath<\/code><\/td><td>Nothing: it opens the browser<\/td><td>2 buttons, URL up to 2,000 characters<\/td><td>At your redirect<\/td><\/tr><tr><td>Phone button<\/td><td><code>WAApi\/template<\/code> <code>buttons[].phone_number<\/code><\/td><td>No<\/td><td>Nothing: it starts a call<\/td><td>1 button, number up to 20 characters<\/td><td>No<\/td><\/tr><tr><td>Copy code<\/td><td>Authentication templates<\/td><td>Per Meta, set at send time<\/td><td>Nothing: it copies to the clipboard<\/td><td>1 button, code up to 15 characters<\/td><td>No<\/td><\/tr><tr><td>Context ID<\/td><td>Set by WhatsApp<\/td><td>n\/a<\/td><td><code>context.id<\/code><\/td><td>n\/a<\/td><td>Store it, don&#8217;t route on it<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The limits combine two sources. The knowledge base says up to 3 quick replies, up to 2 call-to-action buttons, and button text up to 20 characters. Meta&#8217;s <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/templates\/components\" target=\"_blank\" rel=\"noopener nofollow\">template components reference<\/a> is looser: up to 10 buttons in total, up to 10 quick replies, 2 URL buttons, 1 phone number button, 1 copy code button, labels up to 25 characters, URLs up to 2,000 characters with one variable at the end, phone numbers up to 20 characters. When two limits disagree, build to the smaller one. A template that passes the stricter check passes both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two Meta rules matter for layout. Quick replies and other buttons must sit in two groups, so &#8220;Quick Reply, Quick Reply, URL&#8221; is fine and &#8220;Quick Reply, URL, Quick Reply&#8221; is rejected. And templates with four or more buttons, or a quick reply mixed with any other type, can&#8217;t be viewed on WhatsApp desktop clients; the person gets told to open the message on their phone. If your audience reads WhatsApp on a laptop at work, that second rule might decide your design for you.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"validate-buttons\" class=\"wp-block-heading\">Validating the Buttons Array Before You Submit<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Template review takes time, and a rejected template costs you a round of it. Most button rejections are mechanical, so catch them in code before the POST.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s what the validator checks, in plain words:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The array isn&#8217;t empty, and every <code>type<\/code> is one of <code>QUICK_REPLY<\/code>, <code>URL<\/code>, <code>PHONE_NUMBER<\/code> or <code>COPY_CODE<\/code>.<\/li>\n\n\n\n<li>At most 3 quick replies and at most 2 call-to-action buttons (URL plus phone), at most 2 URL buttons and 1 phone button.<\/li>\n\n\n\n<li><code>COPY_CODE<\/code> only on an <code>AUTHENTICATION<\/code> template.<\/li>\n\n\n\n<li>Every label is 1 to 20 characters.<\/li>\n\n\n\n<li>Every URL starts with <code>https:\/\/<\/code>, is at most 2,000 characters, and has at most one variable, which must be <code>{{1}}<\/code> at the very end.<\/li>\n\n\n\n<li>Phone numbers are at most 20 characters.<\/li>\n\n\n\n<li>Quick reply labels are unique, ignoring case and surrounding spaces.<\/li>\n\n\n\n<li><strong>Quick replies come first.<\/strong> Meta allows either group to go first. This validator doesn&#8217;t, and the payload design section explains why.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That last rule is a choice, not a platform requirement. It costs nothing: &#8220;Confirm, Reschedule, Track order&#8221; looks the same to the person as &#8220;Track order, Confirm, Reschedule&#8221; once WhatsApp lays it out, and it removes a whole class of off-by-one bugs.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Run the validator in CI against every template definition you keep in your repo, and run it again right before the create call. The function lives in the Python module further down; this is how you&#8217;d call it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>from wabuttons import validate_buttons, ButtonError\n\nbuttons = &#91;\n    {\"type\": \"QUICK_REPLY\", \"text\": \"Confirm\"},\n    {\"type\": \"QUICK_REPLY\", \"text\": \"Reschedule\"},\n    {\"type\": \"URL\", \"text\": \"Track order\",\n     \"url\": \"https:\/\/example.com\/t\/{{1}}\"},\n]\ntry:\n    validate_buttons(buttons, \"UTILITY\")\nexcept ButtonError as err:\n    raise SystemExit(f\"fix the template before submitting: {err}\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Send the array as a JSON string in the <code>buttons<\/code> form field of <code>WAApi\/template<\/code>, with the <code>apikey<\/code> header and <code>userid<\/code>. Keep the template definition, including your own key for each quick reply, in your database too. The platform&#8217;s copy holds labels; yours holds meaning.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"payload-design\" class=\"wp-block-heading\">Designing the Payload: Signed Tokens, Not Labels<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">When a tap arrives, Meta&#8217;s <a href=\"https:\/\/developers.facebook.com\/documentation\/business-messaging\/whatsapp\/webhooks\/reference\/messages\/button\" target=\"_blank\" rel=\"noopener nofollow\">button webhook reference<\/a> shows two strings: <code>button.payload<\/code> and <code>button.text<\/code>. In Meta&#8217;s own example both are &#8220;Unsubscribe&#8221;. So unless you set the payload yourself, plan on it being the label, and on routing by label whether you meant to or not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Labels are bad routing keys for four reasons:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>They repeat across templates.<\/strong> &#8220;Confirm&#8221; on your appointment template and &#8220;Confirm&#8221; on your delivery template are the same string. A tap tells you nothing about which one was tapped.<\/li>\n\n\n\n<li><strong>They repeat across sends.<\/strong> Even within one template, every copy has the same label. Without more information, a tap on Tuesday&#8217;s reminder and a tap on Monday&#8217;s look identical.<\/li>\n\n\n\n<li><strong>People can type them.<\/strong> Someone can type &#8220;Confirm&#8221; as a normal message. Meta marks it <code>type: \"text\"<\/code>, but anything that collapses types downstream (a CRM sync, an export, a support tool) will lose that difference.<\/li>\n\n\n\n<li><strong>They change.<\/strong> Marketing rewrites &#8220;Confirm&#8221; to &#8220;Yes, I&#8217;ll be there&#8221; in the next template version, and your router quietly stops matching.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to put your own reference in the payload, per send. The shape used here is <code>key.ref.sig<\/code>:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>key<\/code> is your stable name for the button, like <code>confirm<\/code> or <code>stop<\/code>. Lowercase letters, digits and underscores, up to 16 characters.<\/li>\n\n\n\n<li><code>ref<\/code> is 13 characters of base32 from 8 random bytes. It names one send of one template to one person. You save it before you send.<\/li>\n\n\n\n<li><code>sig<\/code> is the first 10 base32 characters of an HMAC-SHA256 over <code>key.ref<\/code> with a server-side secret.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">That gives a token of at most 41 ASCII characters, like <code>confirm.ABCDEFGHIJKLM.QWERTYUIOP<\/code>. It carries no phone number, no order ID and nothing personal, so it&#8217;s safe in logs and in the platform&#8217;s reports.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The signature is what turns &#8220;a string came back&#8221; into &#8220;a string we issued came back&#8221;. Nobody can type a valid token by accident, a payload from another system&#8217;s template can&#8217;t collide with yours, and if someone copies a token from one chat into another, it still points at the original send, where your expiry and first-answer rules apply.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why not put the order ID straight into the payload? Because then the payload is personal data travelling through Meta and the platform, it&#8217;s guessable, and changing your ID format breaks old buttons. A random ref that you look up in your own table has none of those problems.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;ve built RCS suggestion postbacks, this will feel familiar. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-rich-cards-carousels-content-model-validation\/\">RCS rich cards article<\/a> uses the same idea with a fixed <code>_[message_id]<\/code> suffix. WhatsApp gives you the whole payload, so you can make it signed.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"sending\" class=\"wp-block-heading\">Sending With buttonsPayload and buttonUrlPath<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>buttonsPayload<\/code> is a JSON object in a form field. Keys are <code>button1<\/code> to <code>button3<\/code>, values are your tokens. Keep the key numbering aligned with the order of quick replies in the template, which is why the validator insists that quick replies come first: whether the platform counts <code>button1<\/code> as the first quick reply or the first button of any type, those are the same button.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Save the issue row before you call the API. If the send succeeds and your process dies before it writes the ref, the first tap will look like a payload you never issued.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the send with cURL. The trap in this one is the form encoding. With <code>-F<\/code>, cURL treats a value that starts with <code>@<\/code> or <code>&lt;<\/code> as a file to read. Your JSON starts with <code>{<\/code>, so it&#8217;d work today, and break the day someone changes the payload format. <code>--form-string<\/code> sends every value as literal text.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>#!\/usr\/bin\/env bash\n# Send an approved template with per-send button payloads.\n# --form-string sends the JSON as text; -F would treat a leading @ or &lt; as a file.\nset -euo pipefail\nPAYLOAD='{\"button1\":\"confirm.ABCDEFGHIJKLM.QWERTYUIOP\",\"button2\":\"resched.ABCDEFGHIJKLM.ASDFGHJKLZ\"}'\ncurl --silent --show-error --fail-with-body \\\n  'https:\/\/unify.smsgateway.center\/WAApi\/send' \\\n  --header \"apikey: ${SGC_API_KEY}\" \\\n  --form-string \"userid=${SGC_USERID}\" \\\n  --form-string 'wabaNumber=9170396xxxxx' \\\n  --form-string 'mobile=919876543210' \\\n  --form-string 'sendMethod=quick' \\\n  --form-string 'msgType=text' \\\n  --form-string 'templateName=appt_confirm_v2' \\\n  --form-string 'msg=Hi {#var#}, your appointment is on {#var#}.' \\\n  --form-string \"buttonsPayload=${PAYLOAD}\" \\\n  --form-string 'buttonUrlPath=a\/7KQ2M9' \\\n  --form-string 'output=json'<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The tokens above are placeholders to show the shape; real ones come from the module. <code>msg<\/code> follows the same rules as any template send, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/whatsapp-business-api-wire-contract\/\">wire contract article<\/a> covers it and the reply envelope. Two things are specific to buttons:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>One payload per quick reply, every time.<\/strong> If you send <code>button1<\/code> and <code>button2<\/code> but the template has three quick replies, nothing pins down what the third carries on a tap; in Meta&#8217;s example an unset payload reads the same as the label. Your classifier will treat it as untracked. Send all of them.<\/li>\n\n\n\n<li><strong><code>buttonUrlPath<\/code> is the variable part only.<\/strong> The template&#8217;s URL ends in <code>{{1}}<\/code>; this field fills it. Percent-encode it, and keep it opaque (more on that in the URL buttons section).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A group or file send (<code>sendMethod<\/code> of <code>group<\/code> or <code>file<\/code>) is one request with one <code>buttonsPayload<\/code>, so every copy carries the same tokens. That&#8217;s fine for analytics (&#8220;how many people tapped Confirm on this campaign&#8221;) but it can&#8217;t tell you which person&#8217;s appointment to confirm, except through the sender&#8217;s number on the tap. If each person&#8217;s answer changes something specific to them, send per person with <code>sendMethod=quick<\/code> and a ref per message.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"mo-webhook\" class=\"wp-block-heading\">Receiving the Tap on the MO Webhook<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The MO webhook delivers inbound WhatsApp messages as JSON. The knowledge base <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/how-do-i-understand-and-use-the-mo-response-format-when-setting-up-a-webhook-for-whatsapp-business-api-with-smsgatewaycenter\/\">MO format entry<\/a> shows a flat object with <code>metadata<\/code>, <code>messaging_product<\/code>, <code>messages<\/code> and <code>contacts<\/code>, and a text message inside <code>messages<\/code> with <code>from<\/code>, <code>id<\/code>, <code>text.body<\/code>, <code>type<\/code> and <code>timestamp<\/code>. That&#8217;s the inner <code>value<\/code> of Meta&#8217;s webhook, without the <code>entry[].changes[]<\/code> wrapper around it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a quick reply tap, Meta&#8217;s button reference shows what goes in <code>messages[]<\/code>:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"context\": {\n    \"from\": \"15550783881\",\n    \"id\": \"wamid.HBgLMTQxMjU1NTA4MjkVAgASGBQzQUNCNjk5RDUwNUZGMUZEM0VBRAA=\"\n  },\n  \"from\": \"16505551234\",\n  \"id\": \"wamid.HBgLMTY1MDM4Nzk0MzkVAgASGBQzQUFERjg0NDEzNDdFODU3MUMxMAA=\",\n  \"timestamp\": \"1750091045\",\n  \"type\": \"button\",\n  \"button\": {\n    \"payload\": \"Unsubscribe\",\n    \"text\": \"Unsubscribe\"\n  }\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Read it field by field:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>type<\/code> is <code>button<\/code>. A typed message is <code>text<\/code>. That one field is the difference between a tap and someone typing the label.<\/li>\n\n\n\n<li><code>button.payload<\/code> is what you sent in <code>buttonsPayload<\/code>. <code>button.text<\/code> is the label they saw.<\/li>\n\n\n\n<li><code>id<\/code> is the WhatsApp message ID of the tap itself. It&#8217;s your dedupe key.<\/li>\n\n\n\n<li><code>context.id<\/code> is the message ID of the message that contained the button. Store it. Don&#8217;t route on it: your token already says which send this was, without you having to map WhatsApp message IDs back to your sends.<\/li>\n\n\n\n<li><code>timestamp<\/code> is a <strong>string<\/strong> of Unix seconds. <code>\"1750091045\"<\/code> is 2025-06-16 16:24:05 UTC. The inbox poll, by contrast, gives <code>timestamp<\/code> as a number in milliseconds (<code>1752480000000<\/code> is 2025-07-14 08:00:00 UTC). Mix the two up and your &#8220;tapped within 24 hours&#8221; check is off by a factor of a thousand.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Parse both shapes, flat and wrapped. It costs four lines and means you don&#8217;t care which one arrives. Then do the one thing a webhook handler must do quickly: write the tap somewhere durable, keyed on <code>id<\/code>, and answer 200. Decide what it means afterwards, out of the request path.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s a Node receiver. The trap it&#8217;s built around is the timestamp: it refuses anything that isn&#8217;t 9 to 11 digits of seconds rather than guessing the unit. It also checks a shared secret in a custom header, which you add in the portal&#8217;s webhook form under Add Custom Headers.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ MO webhook receiver for WhatsApp quick-reply taps (Node 18+).\nimport { createServer } from 'node:http';\nimport { timingSafeEqual } from 'node:crypto';\n\nconst SHARED = process.env.MO_HEADER_SECRET ?? '';\n\nfunction headerOk(value) {\n  const a = Buffer.from(String(value ?? ''));\n  const b = Buffer.from(SHARED);\n  return SHARED.length &gt; 0 &amp;&amp; a.length === b.length &amp;&amp; timingSafeEqual(a, b);\n}\n\n\/\/ Webhook timestamps are strings in seconds; inbox rows are numbers in ms.\nexport function tapTimeMs(m) {\n  const s = String(m.timestamp ?? '');\n  if (!\/^\\d{9,11}$\/.test(s)) throw new Error(`bad timestamp ${s}`);\n  return Number(s) * 1000;\n}\n\nexport function extractTaps(body) {\n  const values = Array.isArray(body.entry)\n    ? body.entry.flatMap((e) =&gt; (e.changes ?? &#91;]).map((c) =&gt; c.value ?? {}))\n    : &#91;body];\n  return values.flatMap((v) =&gt; (v.messages ?? &#91;])\n    .filter((m) =&gt; m.type === 'button')\n    .map((m) =&gt; ({\n      wamid: String(m.id),\n      sender: String(m.from),\n      contextId: String(m.context?.id ?? ''),\n      payload: String(m.button?.payload ?? ''),\n      label: String(m.button?.text ?? ''),\n      tappedAtMs: tapTimeMs(m),\n    })));\n}\n\n\/\/ save(tap) must be an idempotent insert keyed on wamid.\nexport function makeHandler(save) {\n  return async (req, res) =&gt; {\n    if (req.method !== 'POST' || !headerOk(req.headers&#91;'x-mo-secret'])) {\n      res.writeHead(401).end();\n      return;\n    }\n    let raw = '';\n    for await (const chunk of req) raw += chunk;\n    let body;\n    try { body = JSON.parse(raw); } catch {\n      res.writeHead(200).end(); \/\/ bad body: log it, never ask for a retry\n      return;\n    }\n    for (const tap of extractTaps(body)) await save(tap);\n    res.writeHead(200).end(); \/\/ after the durable insert, before routing\n  };\n}\n\nif (process.argv&#91;2] === 'serve') {\n  const rows = new Map();\n  createServer(makeHandler(async (t) =&gt; { rows.set(t.wamid, t); }))\n    .listen(8080);\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The header name <code>x-mo-secret<\/code> is yours to choose; whatever you type in the portal is what the handler checks. The handler only accepts POST. If you picked GET as the webhook&#8217;s send method in the portal, change that line to match.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>save<\/code> has to be an idempotent insert. In SQL that&#8217;s an <code>INSERT ... ON CONFLICT (wamid) DO NOTHING<\/code>, and the tables section has the schema. Text messages and anything else that isn&#8217;t a tap fall through <code>extractTaps<\/code>; send them to your normal inbound path, which for typed keywords is the routing in <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/two-way-sms-keyword-routing-auto-reply-design\/\">two-way SMS keyword routing<\/a> adapted to WhatsApp.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"classify\" class=\"wp-block-heading\">Classifying a Tap in a Fixed Order<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Once the tap is stored, a worker decides what it means. Do the checks in a fixed order, because the order is the policy.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-button-tap-flow.svg\"><img decoding=\"async\" src=\"https:\/\/www.smsgatewaycenter.com\/blog\/wp-content\/uploads\/2026\/11\/diagram-whatsapp-button-tap-flow.svg\" alt=\"Three-panel flow from sending a template with signed button tokens, to receiving the tap on the MO webhook, to classifying it in a fixed order.\" class=\"wp-image-3164\"\/><\/a><figcaption class=\"wp-element-caption\">Store every tap once, then decide. The order of the checks is the policy.<\/figcaption><\/figure>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Seen this <code>wamid<\/code> before?<\/strong> It&#8217;s a replay. Stop. Nothing else runs twice.<\/li>\n\n\n\n<li><strong>Is the payload a token you signed?<\/strong> If not, check the label against your opt-out labels. A match suppresses the number. Otherwise the tap is untracked: route it to a person or your generic inbound handler.<\/li>\n\n\n\n<li><strong>Is the button key an opt-out key?<\/strong> Suppress now. Expiry and earlier answers don&#8217;t matter.<\/li>\n\n\n\n<li><strong>Do you know this ref, and was this key issued with it?<\/strong> If not, it&#8217;s an unknown ref. Log it loudly; it usually means you sent before saving the issue row.<\/li>\n\n\n\n<li><strong>Is it past <code>expires_at<\/code>?<\/strong> It&#8217;s expired. Tell the person nicely if the conversation allows it (see the window section), and don&#8217;t act on it.<\/li>\n\n\n\n<li><strong>Has this ref already been answered?<\/strong> Keep the first answer. A second tap, even on a different button, is recorded but doesn&#8217;t change anything.<\/li>\n\n\n\n<li><strong>Otherwise it&#8217;s accepted.<\/strong> Route by <code>button_key<\/code>.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;First tap wins&#8221; is deliberate. People tap Confirm, then change their mind and tap Reschedule. If the second tap overrides the first, your system&#8217;s state depends on the order in which two webhooks arrive, and that order isn&#8217;t promised anywhere. If the first tap wins, a change of mind becomes a new conversation (&#8220;You confirmed already. Want to reschedule instead?&#8221;), which is a far easier thing to get right.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Opt-outs jump the queue because the cost of getting them wrong is asymmetric. Ignoring an expired Confirm costs you a booking. Ignoring a Stop costs you a complaint and maybe more. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/opt-out-suppression-sms-whatsapp-rcs-telegram\/\">opt-out and suppression article<\/a> has the suppression ledger these taps should write to.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"python-module\" class=\"wp-block-heading\">The Python Module, Tested<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole thing in one file: the validator, token issuing and checking, the send fields, the MO parser and the classifier. It uses only the standard library. The trap this sample is built around is the opt-out order: an expired or already-answered Stop still has to suppress, and the tests check exactly that.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\"\"\"WhatsApp template buttons: validate, issue payloads, match taps.\"\"\"\nimport base64\nimport hashlib\nimport hmac\nimport json\nimport re\nimport secrets\nfrom dataclasses import dataclass\nfrom urllib.parse import quote\n\n# Stricter of the platform KB and Meta's component limits.\nMAX_QUICK_REPLIES = 3\nMAX_CTA = 2\nMAX_LABEL = 20\nMAX_URL = 2000\nMAX_PHONE = 20\nTOKEN_RE = re.compile(r\"^&#91;a-z0-9_]{1,16}\\.&#91;A-Z2-7]{13}\\.&#91;A-Z2-7]{10}$\")\nKEY_RE = re.compile(r\"^&#91;a-z0-9_]{1,16}$\")\n\n\nclass ButtonError(ValueError):\n    pass\n\n\ndef validate_buttons(buttons, category):\n    \"\"\"Check a WAApi\/template `buttons` array before you submit it.\"\"\"\n    if not isinstance(buttons, list) or not buttons:\n        raise ButtonError(\"buttons must be a non-empty list\")\n    types = &#91;b.get(\"type\") for b in buttons]\n    qr = &#91;b for b in buttons if b.get(\"type\") == \"QUICK_REPLY\"]\n    cta = &#91;b for b in buttons if b.get(\"type\") in (\"URL\", \"PHONE_NUMBER\")]\n    if len(qr) &gt; MAX_QUICK_REPLIES:\n        raise ButtonError(\"more than %d quick replies\" % MAX_QUICK_REPLIES)\n    if len(cta) &gt; MAX_CTA:\n        raise ButtonError(\"more than %d call-to-action buttons\" % MAX_CTA)\n    if types.count(\"URL\") &gt; 2 or types.count(\"PHONE_NUMBER\") &gt; 1:\n        raise ButtonError(\"too many URL or phone buttons\")\n    if \"COPY_CODE\" in types and category != \"AUTHENTICATION\":\n        raise ButtonError(\"COPY_CODE needs an AUTHENTICATION template\")\n    # Quick replies first, so buttonN in buttonsPayload is unambiguous.\n    if qr and types&#91;:len(qr)] != &#91;\"QUICK_REPLY\"] * len(qr):\n        raise ButtonError(\"put every QUICK_REPLY before other buttons\")\n    for b in buttons:\n        t = b.get(\"type\")\n        if t not in (\"QUICK_REPLY\", \"URL\", \"PHONE_NUMBER\", \"COPY_CODE\"):\n            raise ButtonError(\"unknown button type %r\" % t)\n        if t != \"COPY_CODE\":\n            label = b.get(\"text\", \"\")\n            if not label or len(label) &gt; MAX_LABEL:\n                raise ButtonError(\"label %r must be 1-%d chars\"\n                                  % (label, MAX_LABEL))\n        if t == \"URL\":\n            url = b.get(\"url\", \"\")\n            if not url.startswith(\"https:\/\/\") or len(url) &gt; MAX_URL:\n                raise ButtonError(\"URL must be https and &lt;= 2000 chars\")\n            if \"{{\" in url and not url.endswith(\"{{1}}\"):\n                raise ButtonError(\"the one URL variable goes at the end\")\n            if url.count(\"{{\") &gt; 1:\n                raise ButtonError(\"a URL button takes one variable\")\n        if t == \"PHONE_NUMBER\":\n            if len(b.get(\"phone_number\", \"\")) &gt; MAX_PHONE:\n                raise ButtonError(\"phone_number is over 20 chars\")\n    labels = &#91;b&#91;\"text\"].strip().lower() for b in qr]\n    if len(set(labels)) != len(labels):\n        raise ButtonError(\"quick reply labels must differ\")\n    return True\n\n\ndef _b32(raw):\n    return base64.b32encode(raw).decode(\"ascii\").rstrip(\"=\")\n\n\ndef _sign(secret, body):\n    mac = hmac.new(secret, body.encode(\"ascii\"), hashlib.sha256).digest()\n    return _b32(mac)&#91;:10]\n\n\ndef make_token(secret, button_key, ref):\n    if not KEY_RE.match(button_key):\n        raise ButtonError(\"button key must match &#91;a-z0-9_]{1,16}\")\n    body = \"%s.%s\" % (button_key, ref)\n    return \"%s.%s\" % (body, _sign(secret, body))\n\n\ndef read_token(secret, payload):\n    \"\"\"Return (button_key, ref) for a payload we issued, else None.\"\"\"\n    if not isinstance(payload, str) or not TOKEN_RE.match(payload):\n        return None\n    key, ref, sig = payload.split(\".\")\n    if not hmac.compare_digest(sig, _sign(secret, key + \".\" + ref)):\n        return None\n    return key, ref\n\n\ndef build_send_fields(secret, button_keys, url_suffix=None):\n    \"\"\"Form fields for WAApi\/send, plus the ref you must save first.\"\"\"\n    if len(button_keys) &gt; MAX_QUICK_REPLIES:\n        raise ButtonError(\"more keys than quick replies\")\n    ref = _b32(secrets.token_bytes(8))  # 13 chars\n    payload = {\"button%d\" % (i + 1): make_token(secret, k, ref)\n               for i, k in enumerate(button_keys)}\n    fields = {\"buttonsPayload\": json.dumps(payload, separators=(\",\", \":\"))}\n    if url_suffix is not None:\n        fields&#91;\"buttonUrlPath\"] = quote(url_suffix, safe=\"\/\")\n    return ref, fields\n\n\n@dataclass\nclass Tap:\n    wamid: str\n    sender: str\n    context_id: str\n    payload: str\n    label: str\n    sent_at: int  # epoch seconds, parsed from a string\n\n\ndef parse_mo(body):\n    \"\"\"Pull quick-reply taps out of an MO webhook body.\n\n    Accepts the flat shape (metadata, messages, contacts) and Meta's\n    entry&#91;].changes&#91;].value wrapper. Other message types are skipped.\n    \"\"\"\n    values = &#91;]\n    if isinstance(body.get(\"entry\"), list):\n        for entry in body&#91;\"entry\"]:\n            for change in entry.get(\"changes\", &#91;]):\n                values.append(change.get(\"value\", {}))\n    else:\n        values.append(body)\n    taps = &#91;]\n    for value in values:\n        for m in value.get(\"messages\", &#91;]) or &#91;]:\n            if m.get(\"type\") != \"button\":\n                continue\n            btn = m.get(\"button\") or {}\n            taps.append(Tap(\n                wamid=str(m.get(\"id\", \"\")),\n                sender=str(m.get(\"from\", \"\")),\n                context_id=str((m.get(\"context\") or {}).get(\"id\", \"\")),\n                payload=str(btn.get(\"payload\", \"\")),\n                label=str(btn.get(\"text\", \"\")),\n                sent_at=int(str(m.get(\"timestamp\", \"0\"))),\n            ))\n    return taps\n\n\nclass MemoryStore:\n    \"\"\"Test double for the issued_button and button_tap tables.\"\"\"\n\n    def __init__(self):\n        self.issued = {}   # ref -&gt; dict(message_id, keys, expires_at)\n        self.answers = {}  # ref -&gt; button_key (first tap wins)\n        self.seen = set()  # tap wamids\n\n    def issue(self, ref, message_id, keys, expires_at):\n        self.issued&#91;ref] = {\"message_id\": message_id, \"keys\": set(keys),\n                            \"expires_at\": expires_at}\n\n\ndef classify(tap, store, secret, now, optout_keys=(), optout_labels=()):\n    \"\"\"Decide what one tap means. Returns (outcome, detail).\"\"\"\n    if tap.wamid in store.seen:\n        return \"replay\", None\n    store.seen.add(tap.wamid)\n    hit = read_token(secret, tap.payload)\n    if hit is None:\n        # Opt-outs fail open: honour the label even without our token.\n        if tap.label.strip().lower() in {x.lower() for x in optout_labels}:\n            return \"optout_by_label\", tap.sender\n        return \"untracked\", tap.label\n    key, ref = hit\n    if key in optout_keys:\n        # Never let expiry or an earlier answer block an opt-out.\n        return \"optout\", tap.sender\n    row = store.issued.get(ref)\n    if row is None or key not in row&#91;\"keys\"]:\n        return \"unknown_ref\", ref\n    if now &gt; row&#91;\"expires_at\"]:\n        return \"expired\", row&#91;\"message_id\"]\n    if ref in store.answers:\n        return \"already_answered\", store.answers&#91;ref]\n    store.answers&#91;ref] = key\n    return \"accepted\", (row&#91;\"message_id\"], key)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><code>build_send_fields<\/code> returns the ref separately from the form fields so you can save the issue row first, in the same transaction as your outbound message row, and only then call <code>WAApi\/send<\/code>. <code>read_token<\/code> checks the shape with a regex before it touches HMAC, so a 3 KB string typed by a person never reaches the crypto. And <code>hmac.compare_digest<\/code> keeps the signature check constant-time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>MemoryStore<\/code> stands in for the tables described in the tables section. In production, <code>seen<\/code> is the primary key on the tap table and <code>answers<\/code> is a partial unique index, so the database enforces replay protection and first-tap-wins even with several workers running.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The tests run the real module against MO bodies in both shapes:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import json\nimport unittest\n\nimport wabuttons as wb\n\nSECRET = b\"rotate-me-from-your-secret-store\"\n\n\ndef mo(payload, label, wamid, wrap=False):\n    msg = {\"context\": {\"from\": \"919999999999\", \"id\": \"wamid.CTX\"},\n           \"from\": \"919876543210\", \"id\": wamid,\n           \"timestamp\": \"1750091045\", \"type\": \"button\",\n           \"button\": {\"payload\": payload, \"text\": label}}\n    value = {\"messaging_product\": \"whatsapp\", \"messages\": &#91;msg],\n             \"contacts\": &#91;{\"wa_id\": \"919876543210\"}]}\n    if wrap:\n        return {\"object\": \"whatsapp_business_account\",\n                \"entry\": &#91;{\"changes\": &#91;{\"value\": value,\n                                        \"field\": \"messages\"}]}]}\n    return value\n\n\nclass Validate(unittest.TestCase):\n    def test_good_template(self):\n        self.assertTrue(wb.validate_buttons(&#91;\n            {\"type\": \"QUICK_REPLY\", \"text\": \"Confirm\"},\n            {\"type\": \"QUICK_REPLY\", \"text\": \"Reschedule\"},\n            {\"type\": \"URL\", \"text\": \"Track order\",\n             \"url\": \"https:\/\/example.com\/t\/{{1}}\"},\n        ], \"UTILITY\"))\n\n    def test_rejects(self):\n        bad = &#91;\n            (&#91;{\"type\": \"QUICK_REPLY\", \"text\": \"x\" * 21}], \"UTILITY\"),\n            (&#91;{\"type\": \"QUICK_REPLY\", \"text\": \"a\"}] * 4, \"MARKETING\"),\n            (&#91;{\"type\": \"URL\", \"text\": \"Go\", \"url\": \"https:\/\/e.com\/{{1}}\/x\"}],\n             \"UTILITY\"),\n            (&#91;{\"type\": \"URL\", \"text\": \"Go\", \"url\": \"https:\/\/e.com\/\"},\n              {\"type\": \"QUICK_REPLY\", \"text\": \"Yes\"}], \"UTILITY\"),\n            (&#91;{\"type\": \"COPY_CODE\", \"example\": \"250FF\"}], \"UTILITY\"),\n            (&#91;{\"type\": \"QUICK_REPLY\", \"text\": \"Yes\"},\n              {\"type\": \"QUICK_REPLY\", \"text\": \"yes \"}], \"UTILITY\"),\n        ]\n        for buttons, cat in bad:\n            with self.assertRaises(wb.ButtonError):\n                wb.validate_buttons(buttons, cat)\n\n\nclass Tokens(unittest.TestCase):\n    def test_send_fields_and_round_trip(self):\n        ref, fields = wb.build_send_fields(\n            SECRET, &#91;\"confirm\", \"resched\"], url_suffix=\"o\/88 12\")\n        payload = json.loads(fields&#91;\"buttonsPayload\"])\n        self.assertEqual(sorted(payload), &#91;\"button1\", \"button2\"])\n        self.assertEqual(fields&#91;\"buttonUrlPath\"], \"o\/88%2012\")\n        for tok in payload.values():\n            self.assertLessEqual(len(tok), 41)\n            self.assertEqual(wb.read_token(SECRET, tok)&#91;1], ref)\n\n    def test_forged_and_typed(self):\n        ref, fields = wb.build_send_fields(SECRET, &#91;\"confirm\"])\n        tok = json.loads(fields&#91;\"buttonsPayload\"])&#91;\"button1\"]\n        forged = tok&#91;:-1] + (\"A\" if tok&#91;-1] != \"A\" else \"B\")\n        self.assertIsNone(wb.read_token(SECRET, forged))\n        self.assertIsNone(wb.read_token(SECRET, \"Confirm\"))\n        self.assertIsNone(wb.read_token(b\"other-key\", tok))\n\n\nclass Matching(unittest.TestCase):\n    def setUp(self):\n        self.store = wb.MemoryStore()\n        self.ref, f = wb.build_send_fields(SECRET, &#91;\"confirm\", \"stop\"])\n        self.p = json.loads(f&#91;\"buttonsPayload\"])\n        self.store.issue(self.ref, \"msg-1\", &#91;\"confirm\", \"stop\"],\n                         expires_at=2_000)\n\n    def run_tap(self, payload, label, wamid, now=1_000, wrap=False):\n        (tap,) = wb.parse_mo(mo(payload, label, wamid, wrap))\n        return wb.classify(tap, self.store, SECRET, now,\n                           optout_keys={\"stop\"},\n                           optout_labels={\"Stop promotions\"})\n\n    def test_first_tap_wins_and_replay(self):\n        self.assertEqual(self.run_tap(self.p&#91;\"button1\"], \"Confirm\", \"w1\"),\n                         (\"accepted\", (\"msg-1\", \"confirm\")))\n        self.assertEqual(self.run_tap(self.p&#91;\"button1\"], \"Confirm\", \"w1\"),\n                         (\"replay\", None))\n        self.assertEqual(self.run_tap(self.p&#91;\"button1\"], \"Confirm\", \"w2\"),\n                         (\"already_answered\", \"confirm\"))\n\n    def test_expiry_does_not_block_optout(self):\n        self.assertEqual(\n            self.run_tap(self.p&#91;\"button1\"], \"Confirm\", \"w3\", now=5_000),\n            (\"expired\", \"msg-1\"))\n        self.assertEqual(\n            self.run_tap(self.p&#91;\"button2\"], \"Stop\", \"w4\", now=5_000,\n                         wrap=True),\n            (\"optout\", \"919876543210\"))\n\n    def test_label_only_payloads(self):\n        self.assertEqual(self.run_tap(\"Stop promotions\", \"Stop promotions\",\n                                      \"w5\"),\n                         (\"optout_by_label\", \"919876543210\"))\n        self.assertEqual(self.run_tap(\"Confirm\", \"Confirm\", \"w6\"),\n                         (\"untracked\", \"Confirm\"))\n\n    def test_parse_skips_text_and_reads_string_seconds(self):\n        body = mo(self.p&#91;\"button1\"], \"Confirm\", \"w7\")\n        body&#91;\"messages\"].append({\"id\": \"w8\", \"type\": \"text\",\n                                 \"text\": {\"body\": \"Confirm\"}})\n        taps = wb.parse_mo(body)\n        self.assertEqual(len(taps), 1)\n        self.assertEqual(taps&#91;0].sent_at, 1750091045)\n        self.assertEqual(taps&#91;0].context_id, \"wamid.CTX\")\n\n\nif __name__ == \"__main__\":\n    unittest.main()<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Run them with <code>python3 -m unittest -v test_wabuttons<\/code>. All eight pass on Python 3.10: the validator accepts a good template and rejects six bad ones (a 21-character label, four quick replies, a variable in the middle of a URL, a URL before a quick reply, copy code on a utility template, and two labels that differ only by case and a trailing space), tokens round-trip and forged or foreign ones fail, first tap wins, replays are dropped, an expired Stop still suppresses, and string-seconds timestamps parse.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Rotate the signing secret the way you&#8217;d rotate any key: keep the previous secret valid for as long as your longest <code>expires_at<\/code>, check both, and sign new tokens only with the new one. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/api-key-rotation-sms-api-without-dropping-messages\/\">API key rotation guide<\/a> walks through the same overlap pattern for the platform&#8217;s own key.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"optout-buttons\" class=\"wp-block-heading\">Opt-Out Buttons Fail Open<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Marketing templates often carry a &#8220;Stop promotions&#8221; quick reply. Meta&#8217;s own components page uses &#8220;Unsubscribe from Promos&#8221; as its example, and describes opt-out as a common use of quick replies. Treat that button differently from every other button you have.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rule is simple: any tap that plausibly means &#8220;stop&#8221; suppresses the number, and nothing else in the pipeline gets a vote. In practice that means three paths into suppression:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>A signed token whose key is an opt-out key.<\/strong> Suppress, even if the token is expired or that send was already answered. Someone who tapped Confirm last week and taps Stop today has changed their mind about receiving messages, not about the appointment.<\/li>\n\n\n\n<li><strong>An unsigned payload whose label matches an opt-out label.<\/strong> This covers templates you sent before you added tokens, sends where <code>buttonsPayload<\/code> got dropped, and templates someone created in the portal. Keep the list of opt-out labels in your template table, not in code.<\/li>\n\n\n\n<li><strong>A typed message that says stop.<\/strong> That&#8217;s not a button tap, but it&#8217;s the same intent, and your text keyword router should send it to the same suppression ledger.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Suppression is per purpose and per channel. A WhatsApp &#8220;Stop promotions&#8221; tap stops WhatsApp marketing; whether it also stops SMS marketing is your policy decision, and the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-whatsapp-preference-centre-design-purposes-channels\/\">preference centre design<\/a> is the place to make it once. Whatever you decide, write the tap&#8217;s <code>wamid<\/code> into the suppression event as its source, so you can show later exactly which message the person was looking at when they asked you to stop.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Don&#8217;t send a &#8220;you&#8217;ve been unsubscribed&#8221; message unless you&#8217;re sure you&#8217;re inside the service window and your policy calls for one. A confirmation that arrives as a fresh marketing template is the one message that person definitely didn&#8217;t want.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"url-buttons\" class=\"wp-block-heading\">Dynamic URL Buttons and Click Tracking<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A URL button opens the person&#8217;s browser. It doesn&#8217;t send anything back to your business number, so the MO webhook never hears about it. If you want to know who tapped, you find out on your own server.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The template defines the base: <code>https:\/\/example.com\/t\/{{1}}<\/code>. Meta allows one variable and it has to be at the end of the URL. On each send, <code>buttonUrlPath<\/code> fills <code>{{1}}<\/code>. The send page&#8217;s example value is <code>\/photo\/products\/<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Put an opaque token there, not data:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Not an order number or a phone number.<\/strong> The URL sits in the person&#8217;s chat history, in their browser history, and in any link preview. A 7-character random token like <code>a\/7KQ2M9<\/code> reveals nothing.<\/li>\n\n\n\n<li><strong>Percent-encode it.<\/strong> Meta&#8217;s components page warns that unencoded special characters in URL button parameters can fail validation and the send errors out. Spaces become <code>%20<\/code>, <code>|<\/code> becomes <code>%7C<\/code>. <code>quote(suffix, safe=\"\/\")<\/code> in Python does it, and the module&#8217;s test checks that <code>o\/88 12<\/code> goes out as <code>o\/88%2012<\/code>. The encoding rules themselves are <a href=\"https:\/\/www.rfc-editor.org\/info\/rfc3986\" target=\"_blank\" rel=\"noopener nofollow\">RFC 3986<\/a>.<\/li>\n\n\n\n<li><strong>Resolve it on a redirect you own.<\/strong> <code>\/t\/&lt;token><\/code> looks up the send, writes a click row, and redirects to the real page. That row is your URL-button tap count.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Count URL clicks the way you&#8217;d count any SMS link: bots and link previewers will fetch the URL too, so separate first fetch from human visit. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/sms-click-tracking-attribution-smart-links\/\">SMS click tracking article<\/a> covers the redirect, the bot filtering and the attribution model; it applies to WhatsApp URL buttons unchanged.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Phone buttons and copy code buttons give you nothing back at all. Measure a phone button by the calls arriving at that number, and a copy code button by the code being used where it&#8217;s entered.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"data-model\" class=\"wp-block-heading\">The Tables Behind It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three tables carry the whole design. The SQL runs as written in SQLite, and the types map directly to PostgreSQL.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE wa_button_template (\n  template_name  TEXT    NOT NULL,\n  waba_number    TEXT    NOT NULL,\n  position       INTEGER NOT NULL,          -- 1-based, quick replies first\n  button_type    TEXT    NOT NULL,          -- QUICK_REPLY, URL, PHONE_NUMBER\n  label          TEXT    NOT NULL,\n  button_key     TEXT,                      -- your stable key, QR only\n  is_optout      INTEGER NOT NULL DEFAULT 0,\n  PRIMARY KEY (template_name, waba_number, position)\n);\n\nCREATE TABLE wa_button_issue (\n  ref            TEXT PRIMARY KEY,          -- 13-char base32, in the token\n  message_id     TEXT NOT NULL,             -- your outbound row id\n  template_name  TEXT NOT NULL,\n  mobile         TEXT NOT NULL,\n  issued_at      TIMESTAMPTZ NOT NULL,\n  expires_at     TIMESTAMPTZ NOT NULL\n);\n\nCREATE TABLE wa_button_tap (\n  wamid          TEXT PRIMARY KEY,          -- id of the tap message\n  context_wamid  TEXT,\n  sender         TEXT NOT NULL,\n  payload        TEXT NOT NULL,\n  label          TEXT NOT NULL,\n  ref            TEXT,                      -- NULL when untracked\n  button_key     TEXT,\n  outcome        TEXT NOT NULL,\n  tapped_at      TIMESTAMPTZ NOT NULL,\n  received_at    TIMESTAMPTZ NOT NULL\n);\n\n-- First tap wins: at most one accepted answer per issue.\nCREATE UNIQUE INDEX one_answer_per_issue\n  ON wa_button_tap (ref) WHERE outcome = 'accepted';<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The trap here is concurrency. Two workers can classify two taps for the same ref at the same moment, both see &#8220;not answered yet&#8221;, and both accept. The partial unique index makes the second insert fail, and the worker that loses catches the conflict and writes the row again as <code>already_answered<\/code>. In SQLite, a second <code>accepted<\/code> row for the same ref fails with <code>UNIQUE constraint failed: wa_button_tap.ref<\/code>, and a replayed <code>wamid<\/code> fails on the primary key.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A few column notes:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><code>waba_number<\/code>, <code>mobile<\/code> and <code>sender<\/code> are <code>TEXT<\/code>. Phone numbers aren&#8217;t numbers, and some WhatsApp report fields arrive as bare 12-digit JSON numbers. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/lossless-json-parsing-messaging-api-19-digit-ids\/\">lossless JSON parsing guide<\/a> explains why every ID and phone number should become text at one boundary.<\/li>\n\n\n\n<li><code>message_id<\/code> points at your outbound message table, so a tap joins straight to everything else you know about that send. If you followed the <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/outbound-message-table-schema-design\/\">outbound message table design<\/a>, that&#8217;s the row&#8217;s primary key.<\/li>\n\n\n\n<li><code>wa_button_template.button_key<\/code> is where meaning lives. The platform knows &#8220;Confirm&#8221; is the first quick reply; only your table knows it means <code>confirm<\/code>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A useful report falls out for free:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT i.template_name, t.button_key, COUNT(*) AS taps\nFROM wa_button_tap t JOIN wa_button_issue i ON i.ref = t.ref\nWHERE t.outcome = 'accepted'\nGROUP BY i.template_name, t.button_key ORDER BY taps DESC;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Divide by the count of issue rows per template and you have an answer rate per button that doesn&#8217;t double-count anyone who tapped twice.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"inbox-poll\" class=\"wp-block-heading\">What the Inbox Poll Can and Can&#8217;t Tell You<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><code>GET rest\/wa\/v1\/inbox<\/code> lists inbound WhatsApp messages, newest first, with <code>page<\/code> and <code>limit<\/code> (default 50, maximum 200), and filters for <code>wabaNumber<\/code>, <code>mobileNo<\/code>, <code>isReplied<\/code>, <code>agentId<\/code>, <code>waMsgId<\/code> and a date range. Each row has <code>incomingId<\/code>, <code>waMsgId<\/code>, <code>messageType<\/code>, <code>messageTypeId<\/code>, <code>message<\/code>, <code>mediaUrl<\/code>, <code>timestamp<\/code> in milliseconds, <code>charges<\/code> and a few more.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">What it doesn&#8217;t have is a payload field. The <a href=\"https:\/\/www.smsgatewaycenter.com\/developer-api\/whatsapp-inbox\/\">inbox reference<\/a> shows a text row with <code>messageType<\/code> &#8220;text&#8221; and <code>messageTypeId<\/code> 1, and nothing about how a button tap is represented. So a tap in the inbox is, at best, a row whose <code>message<\/code> holds either the label or the payload, under a <code>messageType<\/code> you&#8217;ll have to observe.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That makes the inbox the wrong primary source for button routing and a fine secondary one:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Backfill after a webhook outage.<\/strong> Pull the window you missed, filter to rows from numbers you sent buttons to, and look up each <code>waMsgId<\/code> in your tap table. Anything missing gets inserted with <code>outcome<\/code> set by the same classifier. If <code>message<\/code> holds your token, it&#8217;ll classify normally. If it holds the label, it&#8217;ll classify as untracked or as an opt-out by label, which is the safe result.<\/li>\n\n\n\n<li><strong>Support screens.<\/strong> Agents want to see &#8220;Rahul tapped Reschedule at 10:05&#8221; in the conversation. The inbox gives you the conversation; your tap table gives you the meaning.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Two cautions. Paging by <code>page<\/code> on a list that&#8217;s newest first shifts under you if new messages arrive mid-backfill, so walk with a narrow <code>fromDate<\/code>\/<code>toDate<\/code> window and dedupe on <code>waMsgId<\/code> rather than trusting page boundaries. And <code>timestamp<\/code> here is milliseconds, while the webhook&#8217;s is a string of seconds. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/receiving-messages-four-channel-inbox-contracts\/\">four-channel inbox comparison<\/a> has the full row shape and polling advice.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"service-window\" class=\"wp-block-heading\">Buttons and the 24-Hour Window<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A quick reply tap is an inbound message from the person, and the buttons knowledge base points out that interactions like this open the 24-hour customer care window. Inside that window you can reply with a session message: the send page&#8217;s note says to send an open message and leave out <code>templateName<\/code>, and &#8220;If its in session then the message will get delivered.&#8221;<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;s what makes buttons worth the effort. The template gets you into the conversation; the tap lets you answer with a free-form message that fits what they chose. A practical flow for an appointment reminder:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Send <code>appt_confirm_v2<\/code> with tokens for <code>confirm<\/code> and <code>resched<\/code>, expiring at the appointment time.<\/li>\n\n\n\n<li>On an accepted <code>confirm<\/code>, mark the booking confirmed and reply in session: &#8220;Thanks, see you at 10:30.&#8221;<\/li>\n\n\n\n<li>On an accepted <code>resched<\/code>, reply in session with the next three free slots, and handle the reply as text.<\/li>\n\n\n\n<li>On <code>expired<\/code>, reply in session: &#8220;That appointment has passed. Reply BOOK to make a new one.&#8221;<\/li>\n\n\n\n<li>On <code>already_answered<\/code>, reply in session with what they chose the first time.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The window starts when they tap, not when you sent. A tap at 23:58 on a reminder you sent three days ago gives you a fresh 24 hours. Use <code>tapped_at<\/code> from the webhook, converted from seconds, for any &#8220;are we still in the window&#8221; check, and if a session reply fails, log it and fall back to doing nothing rather than to another template. Someone who tapped a button and got silence is mildly annoyed. Someone who got a second marketing template is worse.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you&#8217;d rather not build the replies in code, the portal can do the simple version. The <a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/kb\/trigger-whatsapp-chatbot-with-template-smsgatewaycenter\/\">chatbot trigger entry<\/a> describes a Postback ID on each quick reply that matches a keyword in Auto Response. That&#8217;s good for fixed answers. As soon as the answer depends on your data (which appointment, which order), the tap has to reach your code.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"ten-mistakes\" class=\"wp-block-heading\">Ten Mistakes With WhatsApp Buttons<\/h2>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Routing on <code>button.text<\/code>.<\/strong> Labels repeat across templates and sends, and they change between template versions. Route on your payload.<\/li>\n\n\n\n<li><strong>Sending no <code>buttonsPayload<\/code>.<\/strong> Then the payload you get back is whatever WhatsApp puts there, which in Meta&#8217;s own example is the label. You&#8217;ve built a label router without meaning to.<\/li>\n\n\n\n<li><strong>Putting personal data in the payload or URL suffix.<\/strong> Order numbers and phone numbers end up in chat history, logs and link previews. Use a random ref and look it up.<\/li>\n\n\n\n<li><strong>Saving the ref after the send.<\/strong> A crash between the two leaves taps pointing at a ref you never saved. Save first, send second.<\/li>\n\n\n\n<li><strong>Doing the work inside the webhook request.<\/strong> Booking systems are slow. Insert the tap, answer 200, route asynchronously.<\/li>\n\n\n\n<li><strong>No dedupe on the tap&#8217;s <code>id<\/code>.<\/strong> A retried push, if the platform retries, and your own inbox backfill can both deliver the same tap twice. The primary key on <code>wamid<\/code> makes the second one a no-op.<\/li>\n\n\n\n<li><strong>Letting the last tap win.<\/strong> Two taps on the same message race. Keep the first answer and turn changes of mind into a new exchange.<\/li>\n\n\n\n<li><strong>Letting expiry block an opt-out.<\/strong> An old Stop is still a Stop.<\/li>\n\n\n\n<li><strong>Mixing up seconds and milliseconds.<\/strong> The webhook&#8217;s <code>timestamp<\/code> is a string of seconds; the inbox&#8217;s is a number of milliseconds. Normalise at parse time.<\/li>\n\n\n\n<li><strong>Counting URL buttons from the webhook.<\/strong> They never arrive there. Count them at your redirect, with bot filtering.<\/li>\n<\/ol>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"decision-matrix\" class=\"wp-block-heading\">Decision Matrix: Quick Reply, URL Button or Typed Keyword<\/h2>\n\n\n\n<figure><div class=\"table-responsive\"><table class=\"table table-striped table-bordered table-hover\"><thead><tr><th>You want the person to&#8230;<\/th><th>Use<\/th><th>You learn about it from<\/th><th>Watch out for<\/th><\/tr><\/thead><tbody><tr><td>Pick one of up to three fixed answers<\/td><td>Quick reply<\/td><td>MO webhook, <code>type: \"button\"<\/code><\/td><td>First tap wins; expiry; opt-out order<\/td><\/tr><tr><td>Open a page about this specific message<\/td><td>URL button with <code>buttonUrlPath<\/code><\/td><td>Your redirect logs<\/td><td>Bots and link previewers; percent-encoding<\/td><\/tr><tr><td>Call your support line<\/td><td>Phone button<\/td><td>Your telephony logs<\/td><td>No per-message attribution at all<\/td><\/tr><tr><td>Copy a one-time code<\/td><td>Copy code (authentication only)<\/td><td>Code entry on your side<\/td><td>Template category must be <code>AUTHENTICATION<\/code><\/td><\/tr><tr><td>Answer freely, or pick from more than three options<\/td><td>A typed reply inside the window<\/td><td>MO webhook, <code>type: \"text\"<\/code><\/td><td>Free text needs a keyword router<\/td><\/tr><tr><td>Stop marketing messages<\/td><td>Quick reply flagged as opt-out<\/td><td>MO webhook, plus label fallback<\/td><td>Must work on old and untokened templates<\/td><\/tr><\/tbody><\/table><\/div><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">If you need more than three choices, don&#8217;t fight the limit. Send the template with two buttons (&#8220;See options&#8221;, &#8220;Not now&#8221;), and when they tap &#8220;See options&#8221; you&#8217;re in the service window, where a session message can carry a list as plain text.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"checklist\" class=\"wp-block-heading\">The Production Checklist<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Every template&#8217;s <code>buttons<\/code> array passes the validator: 3 quick replies or fewer, 2 call-to-action buttons or fewer, labels 20 characters or fewer, quick replies first, URL variable only at the end.<\/li>\n\n\n\n<li>Each quick reply has a stable <code>button_key<\/code> in your template table, and opt-out buttons are flagged.<\/li>\n\n\n\n<li>A signing secret lives in your secret store, with an overlap period for rotation.<\/li>\n\n\n\n<li>The issue row is saved before <code>WAApi\/send<\/code> is called, with a sensible <code>expires_at<\/code>.<\/li>\n\n\n\n<li>Every send of a buttoned template carries a token for every quick reply in <code>buttonsPayload<\/code>.<\/li>\n\n\n\n<li><code>buttonUrlPath<\/code> carries an opaque, percent-encoded token, never personal data.<\/li>\n\n\n\n<li>The WhatsApp MO webhook is configured in the portal with a custom secret header, and the handler rejects requests without it.<\/li>\n\n\n\n<li>The handler inserts taps keyed on <code>wamid<\/code> and answers 200 before any routing.<\/li>\n\n\n\n<li>The classifier runs in the fixed order: replay, token check with label fallback, opt-out, unknown ref, expiry, already answered, accepted.<\/li>\n\n\n\n<li>A partial unique index enforces one accepted answer per ref.<\/li>\n\n\n\n<li>Opt-out taps write to the suppression ledger with the tap&#8217;s <code>wamid<\/code> as the source.<\/li>\n\n\n\n<li>Webhook timestamps are parsed as string seconds and inbox timestamps as number milliseconds.<\/li>\n\n\n\n<li>A backfill job can replay a date window from the inbox through the same classifier.<\/li>\n\n\n\n<li>URL button clicks are counted at your redirect, with bot filtering.<\/li>\n\n\n\n<li>You&#8217;ve sent one buttoned template to a phone you control and compared the raw webhook body with this design (see the next section).<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"unspecified-behaviour\" class=\"wp-block-heading\">Unspecified Behaviour and How to Code Around It<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Some parts of the button round trip aren&#8217;t pinned down by anything you can read today. Each item below gives you the choice that stays correct whichever way the platform behaves, so none of them needs you to wait for an answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>One. Send a token for every quick reply, and put quick replies first.<\/strong> Whether <code>button1<\/code> counts the first quick reply or the first button of any kind isn&#8217;t stated. With quick replies at the front, both readings point at the same button, and with a token on every one, no button falls back to its label.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Two. Send one buttoned template to a phone you control and keep the raw webhook body as a fixture.<\/strong> The knowledge base shows a text MO body; Meta shows a tap body. Capture a real tap from your own account once, check <code>type<\/code>, <code>button.payload<\/code>, <code>button.text<\/code> and <code>context.id<\/code> against this design, and add the captured body to your test suite. If the shape ever drifts, your tests fail before your router does.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Three. Parse the flat body and Meta&#8217;s wrapped body.<\/strong> The knowledge base sample is flat; Meta&#8217;s reference is wrapped in <code>entry[].changes[].value<\/code>. A parser that accepts both doesn&#8217;t need to know which one your account gets.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Four. Treat the inbox <code>message<\/code> on a tap as either the label or the payload.<\/strong> The classifier handles both: a token classifies normally, a label classifies as untracked or as an opt-out. Backfill from the inbox is then safe whatever the field holds.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Five. Accept only the <code>messageType<\/code> values you&#8217;ve seen for taps, and send the rest to a person.<\/strong> The inbox&#8217;s <code>messageType<\/code> and <code>messageTypeId<\/code> value sets aren&#8217;t published. Record the values you observe on your test tap and treat anything new as untracked. An unknown type then fails closed: a person looks at it, nobody gets auto-confirmed.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Six. Make the handler correct whether or not the push is retried.<\/strong> The idempotent insert on <code>wamid<\/code> means one delivery, two deliveries or a backfill all end with exactly one row.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Seven. Pick one router per template: the portal&#8217;s Auto Response or your code.<\/strong> It isn&#8217;t stated whether a <code>buttonsPayload<\/code> value replaces a Postback ID set in the portal, or how Auto Response matches when both exist. If a template drives Auto Response, send it without <code>buttonsPayload<\/code>. If your code routes it, don&#8217;t configure Auto Response keywords for its buttons. Either way, only one system acts on each tap.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eight. Keep payloads short, ASCII and free of JSON-special characters.<\/strong> No length limit for <code>buttonsPayload<\/code> values is published. The 41-character <code>key.ref.sig<\/code> format uses only letters, digits, dots and underscores, which survives any reasonable limit and any encoding layer between you and WhatsApp.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Nine. Build URL buttons for the strictest encoding.<\/strong> It isn&#8217;t stated whether the platform encodes <code>buttonUrlPath<\/code> for you. Sending a value that&#8217;s already percent-encoded and made only of characters that never need encoding (letters, digits, <code>\/<\/code>) is correct either way, because there&#8217;s nothing left to encode twice.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Ten. Enforce the knowledge base limits, not Meta&#8217;s.<\/strong> The platform&#8217;s template builder documents 3 quick replies, 2 call-to-action buttons and 20-character labels; Meta allows 10 buttons and 25 characters. A template inside the smaller limits is valid under both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Eleven. Set <code>expires_at<\/code> from your business rule, not from the chat.<\/strong> The button stays tappable in the person&#8217;s chat for as long as the message exists. Your expiry check is the only thing that stops a tap on last month&#8217;s reminder from confirming something today.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 id=\"faqs\" class=\"wp-block-heading\">FAQs<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I set a quick reply payload when I create the template through the API?<\/strong><br>No, not on the API path. <code>WAApi\/template<\/code> takes the <code>buttons<\/code> array with <code>text<\/code> and <code>type<\/code> only. The payload goes on each send, in <code>buttonsPayload<\/code>. The portal&#8217;s template builder has a Postback ID field for Auto Response, which is a separate mechanism.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What does <code>buttonsPayload<\/code> look like?<\/strong><br>A JSON object in a form field, with keys <code>button1<\/code>, <code>button2<\/code> and <code>button3<\/code> and your payload strings as values, for example <code>{\"button1\":\"confirm.ABCDEFGHIJKLM.QWERTYUIOP\"}<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How do I know which message a tap belongs to?<\/strong><br>Put a reference in the payload. A signed token with a random ref that you saved before sending tells you the send, the template and the button. <code>context.id<\/code> identifies the WhatsApp message that held the button; store it for audit.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Why not just match on the button label?<\/strong><br>Because the same label appears on many templates and every send of each one, people can type it, and it changes between template versions. Labels are for people. Payloads are for code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Do URL button taps reach my webhook?<\/strong><br>No. A URL button opens the browser, and Meta&#8217;s button webhook fires for quick reply taps. Put an opaque token in <code>buttonUrlPath<\/code> and count the visit on your own redirect.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>How many buttons can a template have?<\/strong><br>Build to the knowledge base numbers: up to 3 quick replies or up to 2 call-to-action buttons, with labels up to 20 characters. Meta&#8217;s own limits are higher (10 buttons, 25-character labels), so templates within the smaller numbers pass both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>What happens if someone taps the same button twice?<\/strong><br>Each tap is a separate message with its own <code>id<\/code>, so you&#8217;ll get two. Store both, accept the first, and record the second as already answered. If it&#8217;s the same message delivered twice, the <code>id<\/code> is the same and the second insert does nothing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can an expired button still unsubscribe someone?<\/strong><br>Yes, and it should. Opt-out keys and opt-out labels skip the expiry and already-answered checks entirely.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Does a tap open the 24-hour window?<\/strong><br>Yes. It&#8217;s an inbound message, so you can answer in session by sending without <code>templateName<\/code> while the window is open.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Is the timestamp on a tap in seconds or milliseconds?<\/strong><br>Seconds, as a string, on the webhook: <code>\"1750091045\"<\/code>. The inbox poll uses milliseconds as a number. Convert at parse time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Can I use buttons with a group send?<\/strong><br>Yes, but every copy carries the same tokens, because it&#8217;s one request with one <code>buttonsPayload<\/code>. That&#8217;s fine for campaign-level counts. For per-person actions, send per person with <code>sendMethod=quick<\/code> and a ref per message.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">Turn Every Button Tap Into an Answer You Can Trust<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Try the full round trip on the <a href=\"https:\/\/www.smsgatewaycenter.com\/demo\/\">SMSGatewayCenter sandbox<\/a>: create a template with two quick replies, send it to your own phone with signed tokens in <code>buttonsPayload<\/code>, and watch the tap land on your MO webhook. When you&#8217;re ready to run it at volume on the <a href=\"https:\/\/www.smsgatewaycenter.com\/whatsapp-business-api\/\">WhatsApp Business API<\/a>, <a href=\"https:\/\/www.smsgatewaycenter.com\/contact\/\">talk to the team<\/a> about your templates and webhook setup.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\">More from our blog<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/missed-call-verification-lead-capture-callback-design\/\">Missed Call Verification and Lead Capture: Callback Design, Session Matching and the Lead Pipeline<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/lossless-json-parsing-messaging-api-19-digit-ids\/\">Lossless JSON Parsing for Messaging APIs: 19-Digit IDs, Mixed Types and JSON Inside JSON<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/rcs-rich-cards-carousels-content-model-validation\/\">RCS Rich Cards and Carousels as a Content Model: Validate, Version and Send Cards Without Surprises<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/multi-channel-fallback-sms-whatsapp-rcs-voice-otp-design\/\">Multi-Channel Fallback Design: Platform Failover or Your Own Orchestrator for SMS, WhatsApp, RCS and Voice OTP<\/a><\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><a href=\"https:\/\/www.smsgatewaycenter.com\/blog\/voice-call-api-wire-contract-obd-otp-delivery-reports\/\">Voice Call API Wire Contract: OBD Calls, Voice OTP, Audio Library, Schedules and Delivery Reports<\/a><\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n","protected":false},"excerpt":{"rendered":"<p>A quick reply button is a round trip: you define the label in the template, attach a payload on every send, and get a webhook when someone taps. This guide shows how to validate the buttons array, issue signed per-send payloads with buttonsPayload, build dynamic URL buttons with buttonUrlPath, receive taps on the MO webhook, and decide what each tap means without double-counting, misreading typed text or losing an opt-out.<\/p>\n","protected":false},"author":118,"featured_media":3163,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2010],"tags":[2405,2406,2407,2404,2018,2403,2402,632,2401,2408],"class_list":["post-3162","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-developer-guides","tag-buttonspayload","tag-call-to-action-buttons","tag-chatbot-routing","tag-dynamic-url-button","tag-idempotency","tag-mo-webhook","tag-quick-reply","tag-whatsapp-business-api","tag-whatsapp-template-buttons","tag-whatsapp-webhook"],"_links":{"self":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3162","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/users\/118"}],"replies":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/comments?post=3162"}],"version-history":[{"count":0,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/posts\/3162\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media\/3163"}],"wp:attachment":[{"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/media?parent=3162"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/categories?post=3162"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.smsgatewaycenter.com\/blog\/wp-json\/wp\/v2\/tags?post=3162"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}