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.
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.
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>
This is the part of the API that differs most from a bulk SMS gateway, and it is deliberate: on Pitta, reach is something a customer gives you.
| You may send to | Because |
|---|---|
| Someone who follows your channel | Following is the consent. |
| Someone who opted in | Recorded by you through POST /v1/contacts/opt-in, with the source you supply. |
| Someone who messaged you first | Consent governs who you may start a conversation with. It was never meant to stop you answering a question you were asked. |
| One message request | To someone with no relationship to you, once. It arrives sealed, and you cannot send a second until they accept — a second attempt is refused with 403. |
| An authentication code | The one exemption, and it is earned by the shape of the message. See below. |
Someone who unfollows or opts out cannot be messaged again, however many times they wrote to you before. Deleting a Pitta account also deletes its consent records, so a number that comes back to the platform comes back as a stranger.
+Both 919440222223 and +919440222223 are accepted, so a WhatsApp Cloud API integration ports without rewriting its number handling.GET /v1/contacts/{phone}.POST /v1/messages → 201 with the message id.
| Field | Type | Notes |
|---|---|---|
| to | string | Recipient in E.164, + optional. |
| type | string | One of the types below. An unknown type is 400. |
| text / image / video / audio / document / location / interactive / poll / otp | object | The content block matching type. |
| reply_to | string | Optional. The id of the message you are quoting. Send it only when you want the quote shown. |
| allow_request | boolean | Optional. Send as a message request to someone with no relationship to your channel. |
{
"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.
{ "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.
{ "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.
{ "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 } }
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.
| Rule | Value |
|---|---|
| Templates | login · signup · transaction · password_reset |
| Code | 4–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_minutes | 1–60, default 10. The message deletes itself when the code does, and is never written into a phone backup. |
| Who can send | Verified channels only. An unverified channel gets 403. |
| Cap per recipient | 5 per hour, 15 per day. Over that is 403. |
| The recipient's switch | Anyone can block codes from your channel in their chat menu; you then get 403 with that reason. |
| Call | Does |
|---|---|
| POST /v1/messages/{id}/typing | Shows 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.
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?" }
}
}
}
| Event | Fires when |
|---|---|
| message.received | A customer sends you anything — text, media, a location, a tap on a button or list row. |
| message.status | One of your messages becomes delivered or read. |
| reaction.added | Someone reacts to a message. |
| poll.vote | Someone votes in your poll. |
| user.opted_in | Someone follows you or opts in. |
| user.opted_out | Someone unfollows or opts out — stop sending. |
| user.blocked_business | Someone 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.
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.
| Call | Does |
|---|---|
| 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-in | Records 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.
| Code | Means |
|---|---|
| 400 | The 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. |
| 401 | Missing, malformed, revoked or wrong key. |
| 403 | You 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. |
| 404 | That number has no Pitta account, or the message id is not yours. |
| 429 | Too 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.
| Text body | 4,096 characters |
| Footer buttons | 3 per message |
| Authentication codes | 5 per hour and 15 per day, per recipient; verified channels only |
| Message requests | One unanswered request per person |
| Message retention | Delivered messages are removed from our servers on the schedule the channel sets — two days by default |
| Price | Free. No per-message fee, no packages. |
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.