DEV Community

Cover image for How I Ditched Interakt and Built a Direct WhatsApp Automation Pipeline with Meta Cloud API
Lakshay Tyagi
Lakshay Tyagi

Posted on • Originally published at imlakshay08-complete-ruby-on-rails.hashnode.dev

How I Ditched Interakt and Built a Direct WhatsApp Automation Pipeline with Meta Cloud API

Originally published on Hashnode

A follow-up to my previous post on building Spine Fitness — a production gym management system used by 200+ members daily.

👉 In this post, I’ll walk through why I replaced Interakt with Meta Cloud API, the issues I faced, and how I built a reliable, production-ready WhatsApp automation pipeline.


The Backstory

A few months ago, I published a post about building Spine Fitness — a full-stack gym management system I deployed for a real gym in Dwarka, New Delhi. The system replaced physical notebooks with a Rails app that handled member management, biometric attendance, payments, and WhatsApp notifications.

In that post, I mentioned using Interakt as the WhatsApp API provider.

I no longer use Interakt.

This is the story of why I switched, the absolute nightmare of dealing with Meta's ecosystem, and how I eventually built a clean, direct integration with Meta Cloud API — from scratch — that now reliably sends automated membership reminders to gym members every morning at 10 AM IST.


What Was Working Before (And What Wasn't)

The Interakt setup seemed fine on paper. I had:

  • A connected WhatsApp number (8920)

  • Approved message templates

  • A Rails service (Interakt::SendWhatsapp) making API calls

  • A cron job firing daily at 10 AM

  • A trn_whatsapp_logs table logging every attempt

But when I checked the logs, something was wrong.

Every single message had wl_status = 'QUEUED'. No delivered. No read. Just QUEUED — forever.

SELECT wl_status, COUNT(*) FROM trn_whatsapp_logs GROUP BY wl_status;
-- QUEUED: 13
-- DELIVERED: 0
-- READ: 0
Enter fullscreen mode Exit fullscreen mode

Interakt's dashboard showed messages going out. Single tick on WhatsApp. Members never received anything.

I contacted Interakt support. They said the number would be removed from their system. Weeks passed. It wasn't removed. Meanwhile, messages kept getting queued and silently dropped.

I decided to move on.


The Plan: Go Direct with Meta Cloud API

Instead of relying on a BSP (Business Solution Provider) like Interakt, I'd connect directly to Meta's WhatsApp Cloud API. Same infrastructure the big players use — no middleman, full control.

The plan was simple:

  1. Register 8920 on Meta Cloud API

  2. Create message templates

  3. Replace Interakt::SendWhatsapp with Meta::SendWhatsapp in Rails

  4. Set up a webhook for delivery status updates

Simple in theory. Absolutely chaotic in practice.


Week One: The Meta Setup Maze

Haptik Was Everywhere

When I logged into Meta Business Manager, I found that both my numbers (7011 and 8920) had Haptik (Interakt's parent company) as a partner with full control. Even after Interakt said they removed 8920, Haptik still appeared in the partners tab.

This meant any attempt to register 8920 directly on Meta Cloud API failed with:

"Unsupported post request. Object with ID '1073316579198774' does not exist,
cannot be loaded due to missing permissions..."
Enter fullscreen mode Exit fullscreen mode

The number existed. It just wasn't mine yet.

The Payment Method Loop

After finally getting Interakt to release 8920, I tried adding a payment method to the WhatsApp Business Account. Meta charged my card ₹3 as verification — four separate times — and never actually saved it.

The Developer Console kept screaming "Missing valid payment method" even after the WABA settings clearly showed Visa ****4009 as default. I eventually realized this was a Meta UI bug specific to India accounts. The payment was there. The console just couldn't see it.

The Display Name That Wouldn't Approve

I registered 8920 on Meta Cloud API and submitted "Spine Fitness" as the display name. It went into PENDING_REVIEW.

It stayed there for two days.

Then it came back as DECLINED.

Apparently "Spine Fitness" was too generic. Meta's guidelines require the display name to clearly and uniquely represent your business. I resubmitted as "Spine Fitness Gym Dwarka" — specific enough to pass their guidelines — and it was approved within hours.


The Code Change: Surprisingly Clean

Once the Meta side was sorted, the Rails code change was actually minimal. I created a new service file:

# app/services/meta/send_whatsapp.rb
module Meta
  class SendWhatsapp
    API_URL = "https://graph.facebook.com/v19.0"

    def self.send_template(phone:, template:, body_values:)
      phone = phone.to_s.gsub(/\D/, "").last(10)
      return { http_code: 0, body: {}, raw: "Invalid phone" } unless phone.length == 10

      uri = URI("#{API_URL}/#{ENV['WHATSAPP_PHONE_ID']}/messages")

      payload = {
        messaging_product: "whatsapp",
        to: "91#{phone}",
        type: "template",
        template: {
          name: template,
          language: { code: "en" },
          components: [{
            type: "body",
            parameters: body_values.map { |v| { type: "text", text: v.to_s } }
          }]
        }
      }

      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl = true
      request = Net::HTTP::Post.new(uri)
      request["Authorization"] = "Bearer #{ENV['WHATSAPP_TOKEN']}"
      request["Content-Type"] = "application/json"
      request.body = payload.to_json

      response = http.request(request)
      parsed_body = JSON.parse(response.body) rescue {}
      { http_code: response.code.to_i, body: parsed_body, raw: response.body }
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And changed one line in the job:

# Before
response = Interakt::SendWhatsapp.send_template(...)

# After
response = Meta::SendWhatsapp.send_template(...)
Enter fullscreen mode Exit fullscreen mode

Two environment variables on Render:

WHATSAPP_PHONE_ID=1073316579198774
WHATSAPP_TOKEN=<permanent system user token>
Enter fullscreen mode Exit fullscreen mode

That was it for the sending side.


The Template Problem: Meta Hates "Renew Now"

My original template said:

"Hi {{1}}, your membership at Spine Fitness expires on {{2}}. Renew now to avoid interruption. Contact us or visit the gym."

Meta flagged it as Marketing. Every rewrite got flagged. Anything with "renew", "visit us", or "contact us" triggered the marketing classifier.

The fix was to make it purely transactional — no call to action, no promotional language:

"Your membership at Spine Fitness (ID: {{1}}) will expire on {{2}}. This is an automated notification."

Boring? Yes. Approved as Utility? Also yes. And Utility templates are cheaper and have fewer delivery restrictions than Marketing ones.


The Delivery Problem: App Was Unpublished

After all of this, messages were still only delivering to numbers that had previously messaged 8920 first. My number worked. My mother's worked (after she sent "Hi" to 8920 first). Everyone else got accepted by the API but never actually received the message.

I spent a long time chasing the wrong culprits — display name status, payment method, credit lines, contact book theory. The actual reason was simpler and more embarrassing:

My Meta Developer App was unpublished.

In development mode, WhatsApp API has severe restrictions on who can receive messages. The moment I published the app (which required adding a privacy policy URL, an app icon, and completing app review), messages started going to everyone — no prior interaction required.

That single toggle fixed what two weeks of debugging couldn't.


Adding Webhook Delivery Tracking

With Interakt, I never got real delivery status back. With Meta, I could set up a webhook to receive sent, delivered, and read status updates in real time.

# app/controllers/webhooks/meta_controller.rb
class Webhooks::MetaController < ApplicationController
  skip_before_action :verify_authenticity_token

  def verify
    mode      = params['hub.mode']
    token     = params['hub.verify_token']
    challenge = params['hub.challenge']

    if mode == 'subscribe' && token == ENV['WHATSAPP_WEBHOOK_TOKEN']
      render plain: challenge, status: :ok
    else
      head :forbidden
    end
  end

  def receive
    body = JSON.parse(request.body.read)
    entries = body.dig('entry') || []

    entries.each do |entry|
      entry.dig('changes')&.each do |change|
        change.dig('value', 'statuses')&.each do |status|
          process_status(status)
        end
      end
    end

    head :ok
  rescue => e
    Rails.logger.error "[MetaWebhook] Error: #{e.message}"
    head :ok
  end

  private

  def process_status(status)
    message_id = status['id']
    status_val = status['status']&.upcase
    return unless message_id.present?
    return unless %w[DELIVERED READ FAILED SENT].include?(status_val)

    log = TrnWhatsappLog.find_by(wl_interakt_msg_id: message_id)
    return unless log

    case status_val
    when 'DELIVERED'
      log.update!(wl_status: 'DELIVERED', wl_delivered_at: Time.current)
    when 'READ'
      log.update!(wl_status: 'READ', wl_read_at: Time.current)
    when 'FAILED'
      error = status.dig('errors', 0, 'message') || 'Unknown error'
      log.update!(wl_status: 'FAILED', wl_failed_reason: error)
    end

    Rails.logger.info "[MetaWebhook] Updated log #{log.id}#{status_val}"
  end
end
Enter fullscreen mode Exit fullscreen mode

One important step that wasn't obvious from the docs — I had to explicitly subscribe my app to the correct WABA via API:

curl -X POST \
  "https://graph.facebook.com/v19.0/1603252984268401/subscribed_apps" \
  -H "Authorization: Bearer YOUR_TOKEN"
Enter fullscreen mode Exit fullscreen mode

Without this, the webhook configuration in the Developer Console subscribes to the test WABA, not your production one. Real delivery events never arrive.

Once subscribed correctly, the logs updated in real time:

[MetaWebhook] Updated log 30 → READ
[MetaWebhook] Updated log 32 → READ
Enter fullscreen mode Exit fullscreen mode

The Final State

Here's what the full pipeline looks like now:

cron-job.org (daily 4:30 UTC / 10:00 AM IST)
    │
    ▼
GET /cron/send_expiry_whatsapp
    │
    ▼
MembershipExpiryWhatsappJob (:expiring / :expired)
    │
    ├── Query members expiring in 3 days (or already expired)
    ├── Skip if already DELIVERED or READ
    ├── Call Meta Cloud API
    ├── Log response to trn_whatsapp_logs (status: QUEUED)
    │
    ▼
Meta WhatsApp Cloud API
    │
    ▼
Member's WhatsApp (message delivered)
    │
    ▼
POST /webhooks/meta (delivery status webhook)
    │
    ▼
trn_whatsapp_logs updated: QUEUED → DELIVERED → READ
Enter fullscreen mode Exit fullscreen mode

And the numbers after going live:

Metric Interakt Meta Cloud API
Messages delivered 0 ✅ All
Status tracking ❌ Never updated ✅ Real-time
Cost per message ~₹0.30 + BSP fee ₹0.12 (utility)
Cold outreach ❌ Broken ✅ Works
Setup pain Low Very high

What I'd Tell Myself Before Starting

1. Go direct from the start. BSPs like Interakt add cost and a dependency. If you're building something custom, Meta Cloud API gives you full control and better pricing.

2. Publish your app early. The development mode restriction is the least documented and most impactful limitation. You'll waste days debugging delivery issues that disappear the moment you go live.

3. Display names matter more than you think. Generic names get declined. Be specific — include your city, your category, something that makes the name uniquely yours.

4. Subscribe your webhook to the right WABA. The Developer Console subscribes to the test WABA by default. Make the API call to subscribe your production WABA explicitly.

5. Use Utility templates, not Marketing. Avoid action words. Make it sound like a system notification. It's cheaper and has fewer delivery restrictions.


What's Next

The automation is live and running daily. Members are receiving expiry reminders automatically. The gym owner stopped making manual phone calls.

Next up: a member-facing view so members can check their own subscription status, and SMS fallback for members not on WhatsApp.


Spine Fitness is live at spine-fitness.com. Source code on GitHub.

If you found this useful — or if you've been through the Meta API maze yourself — drop a comment. I'd love to hear your war stories.

Top comments (0)