<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Marcelo Araujo</title>
    <description>The latest articles on DEV Community by Marcelo Araujo (@marceloaraujodev).</description>
    <link>https://dev.to/marceloaraujodev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4219226%2F3b65ac8b-9c8c-4994-8634-a3a47ba9807e.jpg</url>
      <title>DEV Community: Marcelo Araujo</title>
      <link>https://dev.to/marceloaraujodev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/marceloaraujodev"/>
    <language>en</language>
    <item>
      <title>The three things that break every Mercado Pago Pix webhook signature</title>
      <dc:creator>Marcelo Araujo</dc:creator>
      <pubDate>Sat, 31 Oct 2026 15:34:47 +0000</pubDate>
      <link>https://dev.to/marceloaraujodev/mercado-pago-has-no-stripe-trigger-heres-what-that-costs-you-1ja1</link>
      <guid>https://dev.to/marceloaraujodev/mercado-pago-has-no-stripe-trigger-heres-what-that-costs-you-1ja1</guid>
      <description>&lt;h2&gt;
  
  
  Mercado Pago has no &lt;code&gt;stripe trigger&lt;/code&gt;. Here's what that costs you.
&lt;/h2&gt;

&lt;p&gt;If you've integrated Stripe, you know &lt;code&gt;stripe listen --forward-to localhost:3000/api/webhook&lt;/code&gt; and &lt;code&gt;stripe trigger payment_intent.succeeded&lt;/code&gt;. You test webhooks locally, end to end, in seconds.&lt;/p&gt;

&lt;p&gt;If you've integrated Pix, you know the other thing: &lt;strong&gt;there is no equivalent.&lt;/strong&gt; There is no &lt;code&gt;pix trigger&lt;/code&gt;. So you do what everyone does — deploy to a URL, generate a real Pix, pay yourself R$5 from another bank app, and read &lt;code&gt;console.log&lt;/code&gt; in production to find out whether your webhook handler actually works.&lt;/p&gt;

&lt;p&gt;This post is about the three specific things that break in that setup. All three fail &lt;em&gt;quietly&lt;/em&gt;: your endpoint returns &lt;code&gt;200&lt;/code&gt;, Mercado Pago records a success, and nothing ever retries.&lt;/p&gt;




&lt;h3&gt;
  
  
  Trap 1: &lt;code&gt;data.id&lt;/code&gt; is in the query string, not the body
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/webhook?data.id=123456&amp;amp;type=order
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature is computed over &lt;code&gt;data.id&lt;/code&gt;. Not the notification id, not the body — the resource id, which arrives in the &lt;strong&gt;query string&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If your framework hands you a parsed body and nothing else, you can never validate a signature. You need &lt;code&gt;req.url&lt;/code&gt;. In Express that means reading the raw query, not &lt;code&gt;req.body.data.id&lt;/code&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Trap 2: the manifest has a trailing semicolon
&lt;/h3&gt;

&lt;p&gt;The header looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;x-signature: ts=1704908010,v1=618c85345248dd820...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the string you HMAC-SHA256 is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;${data.id};${x-request-id};${ts};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things bite here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;trailing &lt;code&gt;;&lt;/code&gt;&lt;/strong&gt; is part of the manifest. Drop it and every comparison fails.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;x-request-id&lt;/code&gt; comes from the &lt;strong&gt;header&lt;/strong&gt;, and it's absent in some test events —
in which case it's an empty string, but the semicolons around it stay.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ts&lt;/code&gt; is in &lt;strong&gt;seconds&lt;/strong&gt;, not milliseconds.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timingSafeEqual&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dataId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// trailing semicolon&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;valid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare with &lt;code&gt;timingSafeEqual&lt;/code&gt;, not &lt;code&gt;===&lt;/code&gt;. And if the two buffers differ in length, &lt;code&gt;timingSafeEqual&lt;/code&gt; throws — check &lt;code&gt;a.length === b.length&lt;/code&gt; first.&lt;/p&gt;




&lt;h3&gt;
  
  
  Trap 3: order notifications use &lt;code&gt;type=order&lt;/code&gt;, not &lt;code&gt;type=payment&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The Orders API sends &lt;code&gt;type: "order"&lt;/code&gt; and &lt;code&gt;action: "order.processed"&lt;/code&gt;. The legacy Payments API used &lt;code&gt;payment&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you filter on &lt;code&gt;payment&lt;/code&gt; — which most tutorials from 2023 and earlier do — every Orders event falls through your handler, your endpoint returns &lt;code&gt;200&lt;/code&gt; anyway, and Mercado Pago marks the delivery as successful. &lt;strong&gt;It never retries.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the worst of the three because nothing errors. You find out when a customer says they paid and you have no order.&lt;/p&gt;




&lt;h3&gt;
  
  
  The smaller ones that still cost hours
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;notification_url&lt;/code&gt; doesn't exist on Orders.&lt;/strong&gt; Per-order callbacks fail with&lt;br&gt;
&lt;code&gt;400 unsupported_properties: additionalProperties '$.notification_url' not allowed&lt;/code&gt;.&lt;br&gt;
That's a legacy field. On Orders the webhook is configured once per application,&lt;br&gt;
in the panel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;total_amount&lt;/code&gt; alternates between string and number.&lt;/strong&gt; &lt;code&gt;"5.00"&lt;/code&gt; in the create&lt;br&gt;
response, &lt;code&gt;5.00&lt;/code&gt; inside some webhook payloads. &lt;code&gt;amount.split('.')&lt;/code&gt; on a number throws → &lt;code&gt;500&lt;/code&gt; → infinite retries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The webhook already contains the full order.&lt;/strong&gt; &lt;code&gt;data&lt;/code&gt; isn't just an id — it's the whole object. No follow-up GET needed. (The legacy API did send only an id, which is why older tutorials tell you to fetch.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;paidAt&lt;/code&gt; doesn't exist.&lt;/strong&gt; There's no &lt;code&gt;date_approved&lt;/code&gt; field on the Orders webhook. If you need exact settlement time, store your own.&lt;/p&gt;


&lt;h3&gt;
  
  
  Idempotency, and why the retry will bite you
&lt;/h3&gt;

&lt;p&gt;Providers re-deliver webhooks. If your handler processes the same event twice, a customer gets charged once and shipped twice.&lt;/p&gt;

&lt;p&gt;The ordering rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parseWebhook → markProcessed → onEvent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dedupe &lt;strong&gt;before&lt;/strong&gt; your business logic. If you run the handler first and mark afterwards, a crash halfway through leaves a half-applied event and the retry applies it again.&lt;/p&gt;

&lt;p&gt;The HTTP codes matter just as much:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Correct response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Valid, first time&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duplicate&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; &lt;em&gt;(not 409 — the provider would retry)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invalid signature or irrelevant event&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; &lt;em&gt;(not 401 — the provider would retry forever)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your handler threw (transient)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;500&lt;/code&gt; &lt;em&gt;(so it retries)&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Return &lt;code&gt;4xx&lt;/code&gt; on something the provider thinks is retryable and you get redelivery forever. Return &lt;code&gt;5xx&lt;/code&gt; on something it doesn't retry and you lose the payment silently.&lt;/p&gt;




&lt;h3&gt;
  
  
  Testing it without paying yourself R$5
&lt;/h3&gt;

&lt;p&gt;I wrote a small CLI that does one thing: sends a &lt;strong&gt;correctly signed&lt;/strong&gt; Mercado Pago webhook at any URL you point it at, including &lt;code&gt;localhost&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx pix-harness send &lt;span class="nt"&gt;--to&lt;/span&gt; http://localhost:3000/api/webhook
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reproduces all three traps above — &lt;code&gt;data.id&lt;/code&gt; in the query string, the&lt;br&gt;
&lt;code&gt;ts=...,v1=...&lt;/code&gt; header, the manifest with the trailing semicolon. Zero dependencies, MIT, &lt;a href="https://www.npmjs.com/package/pix-harness" rel="noopener noreferrer"&gt;source on npm&lt;/a&gt;.&lt;br&gt;
I built it because I got tired of the deploy-pay-check loop.&lt;/p&gt;

&lt;p&gt;The flag worth knowing about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx pix-harness send &lt;span class="nt"&gt;--to&lt;/span&gt; http://localhost:3000/api/webhook &lt;span class="nt"&gt;--duplicate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It delivers the &lt;strong&gt;same &lt;code&gt;data.id&lt;/code&gt; twice&lt;/strong&gt;, with the same signature. If your handler&lt;br&gt;
creates two orders, you've just found your idempotency bug in two seconds instead&lt;br&gt;
of in a customer's bank statement. Mercado Pago's own "Simular notificação" panel&lt;br&gt;
can't do this — it has no repeat/duplicate control.&lt;/p&gt;




&lt;h3&gt;
  
  
  Bonus: make Mercado Pago approve a Pix by itself
&lt;/h3&gt;

&lt;p&gt;In the test environment, &lt;code&gt;payer.first_name&lt;/code&gt; acts as a scenario switch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;payer.first_name&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;APRO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Order created, then automatically approved&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OTHE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stays pending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CANO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cancelled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Poll &lt;code&gt;getCharge()&lt;/code&gt; and watch &lt;code&gt;action_required&lt;/code&gt; become &lt;code&gt;processed&lt;/code&gt; in about three&lt;br&gt;
seconds. Full cycle, no scanning, no paying, no tunnel.&lt;/p&gt;




&lt;p&gt;That's the whole map. The three traps are the part the docs don't spell out, and&lt;br&gt;
now they're not going to cost you a weekend.&lt;/p&gt;




</description>
      <category>api</category>
      <category>backend</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
