DEV Community

MUHAMMAD WAKEEL
MUHAMMAD WAKEEL

Posted on

WhatsApp API Failed Message Billing & Refund Policy: Error 131026 Webhooks, Direct Meta Billing & Atomic Ledger Reversals

Direct Engineering Summary:
At the protocol level, Meta’s WhatsApp Cloud API strictly enforces per-delivered-message billing: messages that return asynchronous delivery failures—such as Error 131026 (Message Undeliverable / Not on WhatsApp), Error 131047 (24-Hour Window Expired), Error 131049 (Marketing Frequency Cap), or Error 131050 (User Opted Out)—incur $0.00 in wholesale Meta fees. However, developers frequently end up paying for failed messages when using prepaid API aggregators that deduct wallet credits synchronously upon HTTP 200 OK submission without reversing the charge when the asynchronous failure webhook arrives. To eliminate charges on undelivered traffic, 5MinutesAPI implements two distinct billing architectures:

  1. Product A — Instant OTP Route ($0/month subscription, $10 minimum wallet top-up): Uses an event-driven PostgreSQL billing ledger that listens for Meta failure webhooks and executes an atomic wallet refund in under 1 second (< 1s) whenever an OTP fails to deliver (POST /v1/send-otp).
  2. Product B — Dedicated WABA Pro Plan ($99/month flat fee, 0% per-message markup): Uses Direct Meta Billing where the customer’s corporate card is linked directly inside Meta Business Manager, ensuring only delivered messages are ever billed by Meta (POST /v1/send-message).

1. Protocol Analysis: Synchronous Dispatch vs. Asynchronous Failure Webhooks

Why do so many engineering teams get billed for ghost numbers by traditional gateways? Because WhatsApp Cloud API delivery is decoupled into two separate network phases:

+-------------------------------------------------------------------------+
| PHASE 1: Synchronous HTTP Dispatch (T = 0ms to 15ms)                    |
+-------------------------------------------------------------------------+
Your Backend  ---(POST /v1/send-otp)--->  Gateway  ---(Graph API)--->  Meta
Your Backend  <--(200 OK + wamid.xxx)---  Gateway  <--(200 OK)-------  Meta
[!] At T=15ms, Meta has ONLY validated syntax. Handset reachability is UNKNOWN.
[!] Prepaid gateways MUST reserve/deduct wallet funds here to prevent overdrafts.

+-------------------------------------------------------------------------+
| PHASE 2: Asynchronous Delivery Resolution (T = 300ms to 8,000ms)        |
+-------------------------------------------------------------------------+
Meta Routing Engine ---> Checks E.164 Recipient Handset (e.g., 971501234567)
   |
   +--> [Case A: Handset Active] ----> Webhook: status="delivered" (Billable)
   |
   +--> [Case B: No WhatsApp Account] -> Webhook: status="failed", code=131026
                                         |
                                         v
             +-------------------------------------------------------+
             | 5MinutesAPI Golang Webhook Ingest & PostgreSQL Ledger |
             | - Matches wamid + Error 131026                        |
             | - Executes Atomic Credit Reversal to Wallet in < 1s   |
             +-------------------------------------------------------+
Enter fullscreen mode Exit fullscreen mode

If your API gateway lacks the Phase 2 ledger reversal pipeline shown above, every typo in your user signup form turns into permanent financial leakage.


2. Meta WhatsApp Cloud API Error Code & Billing Matrix

When building automated retry and billing reconciliation logic, your webhook consumer must distinguish between non-billable delivery failures, policy throttling, and payload errors:

Meta Error Code Official Error Title Technical Root Cause Meta Wholesale Charge 5MinutesAPI Instant OTP ($0/mo) Wallet Action 5MinutesAPI Dedicated WABA ($99/mo) Billing Action Recommended Backend Retry Strategy
131026 Message Undeliverable Number not on WhatsApp, landline, blocked sender, unaccepted WhatsApp ToS, or outdated app (< 2.21.15.15). $0.00 100% Auto-Refunded in < 1s $0.00 Billed (whatsapp_only drop) Do NOT retry. Flag number in DB & fallback to email/UI prompt.
131047 Re-engagement Message Free-form non-template message sent > 24h after user's last inbound message. $0.00 N/A (OTP route uses pre-approved auth templates) $0.00 Billed Re-dispatch using an approved Utility/Marketing template.
131049 Ecosystem Engagement Cap Meta per-user dynamic frequency cap reached for Marketing templates. $0.00 N/A (Strictly Auth/OTP traffic only) $0.00 Billed Exponential backoff; wait at least 24 to 48 hours before retry.
131050 User Stopped Marketing Recipient used WhatsApp native controls to opt out of your marketing templates. $0.00 N/A $0.00 Billed Never retry marketing. Update marketing_opt_in = false in CRM.
131048 Spam Rate Limit Hit Outbound burst exceeded WABA quality tier throughput limit. $0.00 Prevented via Golang rate-limit smoothing Prevented via Golang HTTP 429 smoothing Queue with jittered backoff.
132000 / 132001 Template / Param Error Mismatched {{variable}} count or unapproved template/locale (en_US). $0.00 Rejected pre-flight (400 Bad Request) $0.00 Billed Fix payload schema or click "Sync Meta Templates" in dashboard.

3. Architectural Trade-Offs: Choosing Between Prepaid Auto-Refunds and Direct Billing

No single billing model fits every engineering stage. Here is the transparent FinOps breakdown of both 5MinutesAPI workspaces—including when each model is the wrong choice:

Route A: Instant OTP Service ($0/mo Subscription, $10 Minimum Wallet Top-Up)

  • Endpoint: POST https://api.5minutesapi.com/v1/send-otp
  • Best For: Startups, mobile apps, and microservices needing live global WhatsApp 2FA/OTPs in 5 minutes without waiting for Meta Business Manager verification.
  • Refund Guarantee: As documented in the 5MinutesAPI Refund Policy, wallet top-ups ($10 minimum) convert into non-expiring API credits. While top-ups cannot be withdrawn back to a bank card, every failed delivery (Error 131026) is automatically refunded back into your wallet balance in milliseconds (< 1s).
  • Architectural Constraint: Strictly locked to authentication/OTP traffic. Cannot be used for marketing campaigns or custom utility notifications.

Route B: Dedicated WABA Pro Plan ($99/mo Flat Fee, 0% Per-Message Markup)

  • Endpoint: POST https://api.5minutesapi.com/v1/send-message
  • Best For: Scaling SaaS platforms, e-commerce stores, and enterprises sending high volumes of Marketing, Utility, and Authentication templates across 244 global destinations.
  • Billing Guarantee: True 0% ($0.00) platform markup. You link your payment card directly inside Meta Business Manager (Terms of Service Section 3). Because Meta only charges for delivered messages, failed deliveries never touch your card.
  • Architectural Constraint: Requires a flat $99/month platform subscription (cancelable anytime, non-prorated mid-month) plus a fresh phone number, business website, and Facebook account for the 2-minute Embedded Signup flow. If your application sends fewer than ~3,000 messages per month and only needs OTPs, Route A ($0/mo) is cheaper.

4. Production Code: Atomic Refunds, E.164 Sanitization & Webhook Handling

Below are production implementations showing how to prevent avoidable 131026 errors via strict E.164 normalization, how the PostgreSQL atomic refund state machine operates, and how to handle failure webhooks in Node.js, Golang, and Python.

A. Golang: Idempotent Atomic Wallet Refund Pattern (PostgreSQL Ledger Core)

When Meta fires a status: "failed" webhook (and sometimes retries that webhook multiple times), the billing ledger must guarantee exactly-once refund execution so a single failed OTP is neither left unrefunded nor refunded twice. Here is the Go + PostgreSQL transactional pattern used in high-concurrency gateways:

package billing

import (
    "context"
    "database/sql"
    "fmt"
    "time"
)

type WebhookFailureEvent struct {
    MessageID string `json:"message_id"`
    Status    string `json:"status"`
    ErrorCode int    `json:"error_code"`
}

// ProcessAutoRefund executes an atomic, idempotent sub-second wallet reversal
// when Meta reports a delivery failure (e.g., Error 131026: Recipient not on WhatsApp).
func ProcessAutoRefund(ctx context.Context, db *sql.DB, event WebhookFailureEvent) error {
    if event.Status != "failed" {
        return nil
    }

    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
    defer cancel()

    tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    if err != nil {
        return fmt.Errorf("begin tx failed: %w", err)
    }
    defer tx.Rollback()

    var walletID string
    var deductedAmountCents int64

    // 1. Lock the original dispatch ledger row ONLY if it hasn't been refunded yet.
    // FOR UPDATE SKIP LOCKED prevents race conditions during concurrent Meta webhook retries.
    queryLock := `
        SELECT wallet_id, cost_cents
        FROM message_dispatch_ledger
        WHERE message_id = $1 AND billing_state = 'DEDUCTED'
        FOR UPDATE SKIP LOCKED
    `
    err = tx.QueryRowContext(ctx, queryLock, event.MessageID).Scan(&walletID, &deductedAmountCents)
    if err == sql.ErrNoRows {
        // Already refunded or unknown message_id -> idempotent no-op
        return nil
    }
    if err != nil {
        return fmt.Errorf("ledger lock error: %w", err)
    }

    // 2. Mark ledger entry as REFUNDED with the exact Meta error code (e.g., 131026)
    updateLedger := `
        UPDATE message_dispatch_ledger
        SET billing_state = 'REFUNDED',
            meta_error_code = $1,
            refunded_at = NOW()
        WHERE message_id = $2
    `
    if _, err := tx.ExecContext(ctx, updateLedger, event.ErrorCode, event.MessageID); err != nil {
        return fmt.Errorf("failed to mark ledger refunded: %w", err)
    }

    // 3. Credit the exact deducted cost back to the user's prepaid wallet balance
    creditWallet := `
        UPDATE developer_wallets
        SET balance_cents = balance_cents + $1,
            updated_at = NOW()
        WHERE id = $2
    `
    if _, err := tx.ExecContext(ctx, creditWallet, deductedAmountCents, walletID); err != nil {
        return fmt.Errorf("failed to credit wallet: %w", err)
    }

    return tx.Commit()
}
Enter fullscreen mode Exit fullscreen mode

B. Node.js / Next.js: Pre-Flight E.164 Sanitization & Error 131026 Webhook Consumer

Over 30% of Error 131026 events in production are caused by developers passing local trunk zeros (e.g., 9710501234567 instead of 971501234567) or failing to cache known non-WhatsApp numbers.

// lib/whatsappOtpService.js
import crypto from "crypto";

// In-memory or Redis cache of numbers that recently returned Error 131026
const nonWhatsappCache = new Set();

/**
 * Normalizes international phone numbers to strict E.164 digits
 * and strips common trunk-zero mistakes (e.g. "+92 (0) 300 1234567" -> "923001234567")
 */
export function sanitizeE164(rawInput) {
  let digits = String(rawInput).replace(/\D/g, "").replace(/^00/, "");

  // Fix common country code + local trunk '0' mistake for UAE (971), PK (92), UK (44)
  digits = digits.replace(/^(971|92|44|966)0(\d+)$/, "$1$2");

  if (digits.length < 8 || digits.length > 15) {
    throw new Error(`Invalid E.164 phone length: ${digits}`);
  }
  return digits;
}

export async function sendInstantOtp(rawPhone, otpCode) {
  const cleanPhone = sanitizeE164(rawPhone);

  // Defensive check: avoid dispatching to numbers already known to lack WhatsApp
  if (nonWhatsappCache.has(cleanPhone)) {
    return {
      dispatched: false,
      reason: "CACHED_131026_NO_WHATSAPP",
      fallback: "PROMPT_EMAIL_OR_ALTERNATE_NUMBER",
    };
  }

  const response = await fetch("[https://api.5minutesapi.com/v1/send-otp](https://api.5minutesapi.com/v1/send-otp)", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.FIVE_MINUTES_API_KEY}`, // 5m_live_********
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: cleanPhone,
      code: String(otpCode),
    }),
  });

  if (!response.ok) {
    throw new Error(`Gateway HTTP error ${response.status}: ${await response.text()}`);
  }

  return await response.json();
}

/**
 * Express / Next.js Webhook Handler for Status & Auto-Refund Events
 */
export function verifyAndHandleStatusWebhook(rawBodyBuffer, signatureHeader, secret) {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(rawBodyBuffer).digest("hex");

  if (
    !signatureHeader ||
    Buffer.byteLength(signatureHeader) !== Buffer.byteLength(expected) ||
    !crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected))
  ) {
    throw new Error("Invalid HMAC-SHA256 webhook signature");
  }

  const payload = JSON.parse(rawBodyBuffer.toString("utf8"));

  if (payload.status === "failed") {
    switch (payload.error_code) {
      case 131026:
        // 5MinutesAPI has already refunded the wallet in <1s.
        // Cache this recipient so our frontend asks the user for a valid WhatsApp number.
        if (payload.recipient_id) nonWhatsappCache.add(payload.recipient_id);
        console.log(`[Auto-Refund Confirmed] Msg ${payload.message_id} failed (131026).`);
        break;
      case 131047:
        console.warn(`[Window Closed 131047] Msg ${payload.message_id}: Switch to a synced template.`);
        break;
      case 131049:
        console.info(`[Frequency Cap 131049] Msg ${payload.message_id}: Delay retry by >= 24h.`);
        break;
    }
  }

  return { ok: true };
}
Enter fullscreen mode Exit fullscreen mode

C. Python: Break-Even FinOps Analyzer (Route A Prepaid vs. Route B Dedicated WABA)

Use this script to determine the exact monthly message threshold where your team should migrate from the $0/month Instant OTP wallet to the $99/month Dedicated WABA Pro Plan:

def calculate_optimal_5minutesapi_route(
    monthly_attempts: int,
    meta_wholesale_rate_usd: float = 0.032,
    routing_margin_pct: float = 0.25,
    error_131026_failure_rate: float = 0.10
) -> dict:
    """
    Calculates exact monthly spend across both 5MinutesAPI workspaces,
    accounting for 100% auto-refunds on Error 131026 undelivered messages.
    """
    billable_delivered_msgs = int(monthly_attempts * (1.0 - error_131026_failure_rate))
    refunded_failed_msgs = monthly_attempts - billable_delivered_msgs

    # Route A ($0/mo Instant OTP): Pays Meta + fractional routing ONLY on delivered messages
    # All failed messages (Error 131026) are auto-refunded in <1s ($0.00 cost)
    route_a_per_msg_fee = meta_wholesale_rate_usd * routing_margin_pct
    route_a_total_usd = billable_delivered_msgs * (meta_wholesale_rate_usd + route_a_per_msg_fee)

    # Route B ($99/mo Dedicated WABA): Pays $99 flat fee + 0% markup on Meta wholesale rate
    route_b_total_usd = 99.00 + (billable_delivered_msgs * meta_wholesale_rate_usd)

    # Break-even point in delivered messages where routing fees equal the $99/mo flat fee
    breakeven_delivered_msgs = int(99.00 / route_a_per_msg_fee)

    return {
        "delivered_messages": billable_delivered_msgs,
        "auto_refunded_131026_messages": refunded_failed_msgs,
        "route_a_instant_otp_total_usd": round(route_a_total_usd, 2),
        "route_b_dedicated_waba_total_usd": round(route_b_total_usd, 2),
        "breakeven_delivered_volume": breakeven_delivered_msgs,
        "recommended_plan": (
            "Route A: Instant OTP ($0/mo)"
            if route_a_total_usd < route_b_total_usd
            else "Route B: Dedicated WABA ($99/mo, 0% Markup)"
        ),
    }

if __name__ == "__main__":
    print("Startup Volume (3,000 attempts/mo):", calculate_optimal_5minutesapi_route(3_000))
    print("Scale Volume (25,000 attempts/mo):", calculate_optimal_5minutesapi_route(25_000))
Enter fullscreen mode Exit fullscreen mode

5. Technical FAQ: Billing, Refunds & Edge Cases

1. Why are wallet top-ups ($10+) non-refundable to my credit card, while failed messages are 100% refunded?

To prevent payment gateway chargeback fraud and telecom credit abuse, deposits converted into prepaid API credits on the Instant OTP Route cannot be withdrawn back to a bank account (Refund Policy Section 3). However, those credits never expire, and whenever an individual API call fails to deliver (Error 131026), the deducted credit is 100% refunded back into your active wallet in under 1 second so you only ever spend credits on delivered OTPs.

2. What happens if I cancel my $99/month Dedicated WABA subscription mid-month?

As detailed in the 5MinutesAPI Refund Policy (Section 4), the Dedicated WABA workspace is a prepaid monthly subscription with no long-term lock-in. If you cancel mid-cycle, you retain full API and dashboard access until the end of your current 30-day billing period, and no future monthly charges are billed.

3. Does retrying Error 131026 or Error 131049 cost money or hurt my WABA Quality Rating?

While Meta does not bill you financially for failed 131026 or 131049 attempts, aggressively retrying undeliverable numbers wastes webhook bandwidth and—if the underlying cause of 131026 is that the recipient blocked your number—repeatedly attempting to message blocked users can negatively impact your Meta WABA quality signals. Always inspect the webhook error_code and halt immediate retries on 131026, 131049, and 131050.


Official Documentation & Policy References

Top comments (0)