Business API · v1

Your system sends. Their phone buzzes.

One REST endpoint for every message type, one signed webhook for everything that comes back. The payloads are shaped like the WhatsApp Cloud API on purpose, so a flow you have already written ports across with a changed base URL and a changed key.

Quick start

Everything lives under https://api.pitta.pro. Issue a key from the app — Channel → Developer → API keys — or from the portal at app.pitta.pro. Then:

# send a message to one of your followers
curl -X POST https://api.pitta.pro/v1/messages \
  -H "Authorization: Bearer $PITTA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "919440222223",
    "type": "text",
    "text": { "body": "Your order is packed and leaves at 4pm." }
  }'

# 201 Created
{ "id": "cmsg_9f2c…", "status": "sent" }

There is a runnable end-to-end matrix in the repo — infra/deploy/api-matrix.sh — which walks every send type and every guard and prints a pass/fail line for each. It is the fastest way to see the whole surface at once.

Authentication

A key belongs to one channel and carries its permissions. Send it as a bearer token on every request:

Authorization: Bearer <key id>:sk_<secret>
  • The secret is shown onceAt creation. Store it in your server's environment; it is never displayed again and we cannot recover it for you.
  • Revoke instantlyFrom the same screen. A revoked key answers 401 on the next call.
  • Server-side onlyNever ship a key in an app, a browser or a public repository. Anyone holding it can message your followers as you.

Phone numbers

  • With or without the +Both 919440222223 and +919440222223 are accepted, so a WhatsApp Cloud API integration ports without rewriting its number handling.
  • The country code is requiredA bare ten-digit number is refused with 400 rather than guessed at.
  • A number with no Pitta account is refused with 404We deliver inside the app only — there is no SMS fallback — so a "sent" for a number that cannot receive it would be a lie. Check first with GET /v1/contacts/{phone}.

Send a message

POST /v1/messages201 with the message id.

FieldTypeNotes
tostringRecipient in E.164, + optional.
typestringOne of the types below. An unknown type is 400.
text / image / video / audio / document / location / interactive / poll / otpobjectThe content block matching type.
reply_tostringOptional. The id of the message you are quoting. Send it only when you want the quote shown.
allow_requestbooleanOptional. Send as a message request to someone with no relationship to your channel.

Message types

Text, with up to three footer buttons

{
  "to": "919440222223",
  "type": "text",
  "text": {
    "body": "Your table is booked for 8pm.",
    "buttons": [
      { "type": "url",  "title": "Directions", "url": "https://maps.example/…" },
      { "type": "call", "title": "Call us",    "phone": "+919491999998" }
    ]
  }
}

Button types are url, call and reply. A fourth button is refused with 400; the body is capped at 4,096 characters.

Media by link

{ "to": "919440222223", "type": "image",
  "image": { "link": "https://cdn.example/new-arrival.jpg",
              "caption": "Just in — 12 pieces only." } }

image, video, audio and document take the same shape; document also takes a filename. We fetch the link once and keep our own copy, so a link that expires later does not empty the customer's thread.

Interactive — buttons and lists

{ "to": "919440222223", "type": "interactive",
  "interactive": {
    "type": "list",
    "body": { "text": "Pick a slot for Thursday" },
    "action": { "button": "Choose", "sections": [
      { "title": "Morning", "rows": [
        { "id": "s9",  "title": "9:00", "description": "30 min" },
        { "id": "s10", "title": "10:00" } ] } ] } } }

A tap comes back on your webhook as an interactive event carrying button_reply or list_reply with the id you set — so you match on ids, not on the words the customer saw. cta_url and call interactives are supported too.

Location and poll

{ "type": "location",
  "location": { "latitude": 17.385, "longitude": 78.4867,
                 "name": "Shop 12, Main Road", "address": "Vizianagaram" } }

{ "type": "poll",
  "poll": { "question": "Which slot suits you?",
            "options": ["9am", "10am"], "allow_multiple": false } }

Authentication codes

A login code is the one message that crosses every consent line — the person asked for it seconds ago on your login screen and is waiting for it. Refusing to deliver would not protect them; it would break their login.

That exemption is bought with the shape of the message rather than your word for it. There is no free-text field anywhere in it: you supply a code and a number of minutes, and the server writes every other word.

{ "to": "919440222223", "type": "otp",
  "otp": { "template": "login", "code": "418206", "expires_minutes": 10 } }

→ delivered as:
  *418206* is your verification code for Acme Bank.
  It expires in 10 minutes. Do not share it with anyone —
  Pitta will never ask you for this code.
RuleValue
Templateslogin · signup · transaction · password_reset
Code4–8 digits or capital letters. A sentence is refused with 400 — that is the whole point.
action (transaction only)A closed list: a payment, a withdrawal, a transfer, an order, a booking, a change to your account.
expires_minutes1–60, default 10. The message deletes itself when the code does, and is never written into a phone backup.
Who can sendVerified channels only. An unverified channel gets 403.
Cap per recipient5 per hour, 15 per day. Over that is 403.
The recipient's switchAnyone can block codes from your channel in their chat menu; you then get 403 with that reason.

Typing and receipts

CallDoes
POST /v1/messages/{id}/typingShows the typing indicator on that thread while your system composes an answer.
POST /v1/messages
{"status":"read","message_id":"…"}
Marks the customer's message read — their second tick turns blue.
GET /v1/messages/{id}Reads a message's current status: sent, delivered, read.

Receipts run in both directions. When your webhook answers 2xx to a message.received event, that inbound message is marked delivered for the customer; replying through the API marks it read. Customers see the same ticks whether you answer from the app or from your own software.

Webhooks

Add an endpoint in Channel → Developer → Webhooks and subscribe to the events you want. Every delivery is a POST with this envelope:

{
  "id": "evt_3b1f…",
  "type": "message.received",
  "created_at": "2026-09-18T09:41:02.118Z",
  "business_id": "cmtd…",
  "data": {
    "conversation_id": "cnv_…",
    "message": {
      "id": "cmsg_…",
      "from": "919440222223",
      "timestamp": "2026-09-18T09:41:01.994Z",
      "type": "text",
      "text": { "body": "Is the blue one still there?" }
    }
  }
}
EventFires when
message.receivedA customer sends you anything — text, media, a location, a tap on a button or list row.
message.statusOne of your messages becomes delivered or read.
reaction.addedSomeone reacts to a message.
poll.voteSomeone votes in your poll.
user.opted_inSomeone follows you or opts in.
user.opted_outSomeone unfollows or opts out — stop sending.
user.blocked_businessSomeone blocks your channel.

An active webhook is a fan-out, not a diversion: the message still lands in your Pitta inbox as well. What it does suppress is the channel's auto-reply, and the in-app composer is locked with a banner while your system owns the conversation — so a customer never gets two answers to one question.

Verifying a delivery

Every delivery carries a Stripe-style signature over the raw body. Verify it before you trust the payload — and compare in constant time.

X-Q-Signature: t=<unix seconds>,v1=<hex HMAC_SHA256(secret, "<t>.<raw body>")>
X-Q-Event-Type: message.received
// Node — verify, with a five-minute tolerance against replay
const { createHmac, timingSafeEqual } = require('crypto');

function verify(secret, rawBody, header, tolerance = 300) {
  const p = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
  const t = parseInt(p.t, 10);
  if (!t || !p.v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > tolerance) return false;
  const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const a = Buffer.from(p.v1), b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

Sign against the raw request body, before any JSON parsing — a re-serialised body will not match. Answer 2xx quickly and do your work afterwards; a non-2xx is retried, and a 2xx on message.received is what marks the customer's message delivered.

Contacts

CallDoes
GET /v1/contacts/{phone}Tells you whether that number is registered (has a Pitta account) and reachable (has a signed-in device with a push token), plus its opt-in state for your channel.
POST /v1/contacts/opt-inRecords consent you collected yourself — pass phone and a source describing where it came from.

Check registered before a bulk run: sending to numbers that never joined is the one mistake that looks like success and isn't.

Errors

CodeMeans
400The request is wrong: unknown type, missing country code, a fourth button, a body over 4,096 characters, an OTP code that is not 4–8 digits or capitals, an expiry outside 1–60 minutes.
401Missing, malformed, revoked or wrong key.
403You are not allowed to message this person: no consent, they opted out, a second message request while the first is unanswered, codes blocked, the OTP cap, or an unverified channel trying to send one.
404That number has no Pitta account, or the message id is not yours.
429Too many requests — back off and retry.

Every error carries a human sentence in its body. Log it: they are written to be read by the person debugging, not by a parser.

Limits

Text body4,096 characters
Footer buttons3 per message
Authentication codes5 per hour and 15 per day, per recipient; verified channels only
Message requestsOne unanswered request per person
Message retentionDelivered messages are removed from our servers on the schedule the channel sets — two days by default
PriceFree. No per-message fee, no packages.

Building something?

Tell us what you are wiring up and we will help you get the first message through — including the parts of the consent model that are easier to ask about than to read.

Chat with us