Pingsir API
Send WhatsApp messages, manage contacts, and receive real-time events from your own systems — over the official WhatsApp Cloud API, through your connected WhatsApp Business Account (WABA).
Support: Something wrong or missing? Report issues at webxletech@gmail.com.
Getting Started
Three steps to your first message.
1. Create an API key
In the Pingsir Dashboard, go to Settings → Integrations → REST API and generate a key. It is shown once — copy it immediately. Keys look like ps_live_….
export PINGSIR_KEY="ps_live_xxxxxxxxxxxxxxxxxxxx"
2. Send an approved template
curl -X POST https://api.pingsir.com/v1/messages \
-H "Authorization: Bearer $PINGSIR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"templateName": "order_confirmation",
"language": "en",
"variables": { "1": "Priya", "2": "Acme Store" }
}'
{ "messageId": "wamid.HBgL...", "status": "SENT", "timestamp": "2026-07-24T09:41:00.000Z" }
3. Receive a reply
Add a webhook endpoint under Settings → Integrations → Webhooks, subscribe to message.received, and Pingsir will POST every inbound message to your URL as it arrives. See Webhooks.
Introduction
The Pingsir API lets your application:
- Send messages — approved templates (anytime) and free-form text/media (inside an open 24-hour session).
- Manage contacts — list, create, read, and update the contacts in your Pingsir workspace.
- Read analytics — outbound delivery and read stats for a date range.
- Fetch inbound media — download files attached to messages your customers send you.
- Receive events — real-time webhooks for message status, inbound messages, opt-outs, and conversation changes.
What the API does not do. It is a messaging and contact API, not an admin console. It cannot: create or submit message templates (build and submit those in the Dashboard, then reference the approved template by name); manage billing, plans, team members, or WABA connection settings; or act across tenants. Everything is scoped to the single workspace that owns the API key.
Authentication
Every request authenticates with a Bearer API key in the Authorization header:
Authorization: Bearer ps_live_xxxxxxxxxxxxxxxxxxxx
- Keys are prefixed
ps_live_. - Generate and revoke keys in Dashboard → Settings → Integrations → REST API. Up to 3 active keys per workspace.
- The plaintext key is returned once, at creation. Pingsir stores only a hash — a lost key cannot be recovered, only revoked and replaced.
- The API never uses session cookies. (The one exception is
GET /media/{path}, which also accepts a Dashboard session for in-app rendering.)
Requests with a missing, malformed, or unknown key return 401. A revoked key returns 401. A key on a plan without API access returns 403 PLAN_LOCKED; a suspended or deleted workspace returns 403 ACCOUNT_INACTIVE.
Plans
The REST API and webhooks are available on the Growth and Pro plans. On Starter, keys can be created but every call returns 403 PLAN_LOCKED.
The 24-hour session window. WhatsApp only allows free-form messages (plain text, media) within 24 hours of the customer's most recent message to you. Outside that window you may send only approved templates.
| You want to send | Inside 24h window | Outside 24h window |
|---|---|---|
| Approved template | ✅ POST /messages |
✅ POST /messages |
| Free-form text | ✅ POST /messages/session |
❌ WINDOW_CLOSED |
| Media (image/video/doc) | ✅ POST /messages/media |
❌ SESSION_EXPIRED |
To re-open a conversation that has gone cold, send a template — the customer's reply starts a fresh 24-hour window.
Base URLs
Two equivalent origins; use whichever suits your setup:
https://api.pingsir.com/v1
https://pingsir.com/api/v1
All paths in this document are relative to one of these. Examples use https://api.pingsir.com/v1.
Rate limits
100 requests per minute, per API key. Exceeding it returns 429 with code RATE_LIMITED; retry after a short pause. Limits are per key, not per IP, so spreading load across keys raises your effective ceiling.
Idempotency
There are no idempotency keys today. If a request times out or you retry after a network error, you may create a duplicate message. Until idempotency keys ship, make retries conservative — prefer confirming via the message.* webhooks over blind re-sends.
Messages API
POST /messages — send a template
Send an approved WhatsApp template. Valid anytime, in or out of the 24-hour window.
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | yes | Recipient in E.164 (e.g. +919876543210). |
templateName |
string | yes | Exact name of an APPROVED template in your workspace. |
language |
string | yes | Template language code, 2–15 chars (e.g. en, en_US, hi). |
variables |
object | no | Body placeholders as a map keyed by position — {"1":"Priya","2":"Acme Store"}. Keys are the {{n}} numbers as strings. |
Success — 201
{ "messageId": "wamid.HBgL...", "status": "SENT", "timestamp": "2026-07-24T09:41:00.000Z" }
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
— | Invalid JSON, failed validation (fieldErrors), or to is not E.164. |
404 |
— | No template with that name in this workspace. |
409 |
— | Template exists but is not APPROVED, or no WhatsApp account is connected. |
402 |
INSUFFICIENT_CREDITS |
Wallet cannot cover the message. |
502 |
— | Meta rejected the send. |
Examples
curl -X POST https://api.pingsir.com/v1/messages \
-H "Authorization: Bearer $PINGSIR_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "+919876543210",
"templateName": "order_confirmation",
"language": "en",
"variables": { "1": "Priya", "2": "Acme Store" }
}'
// Node 18+ (global fetch)
const res = await fetch("https://api.pingsir.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PINGSIR_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
to: "+919876543210",
templateName: "order_confirmation",
language: "en",
variables: { "1": "Priya", "2": "Acme Store" },
}),
});
const data = await res.json();
console.log(data.messageId);
import os, requests
res = requests.post(
"https://api.pingsir.com/v1/messages",
headers={"Authorization": f"Bearer {os.environ['PINGSIR_KEY']}"},
json={
"to": "+919876543210",
"templateName": "order_confirmation",
"language": "en",
"variables": {"1": "Priya", "2": "Acme Store"},
},
)
print(res.json()["messageId"])
Common integration mistakes
variablesis an object keyed by position ({"1":"…"}), not an array. An array will not map correctly.templateNamemust be the approved template's exact name — not its display title, and not a draft.- Templates are created and submitted in the Dashboard, never via the API.
POST /messages/session — send session text
Send a free-form text message. Requires an open 24-hour session with the recipient (they must have messaged you within the last 24 hours).
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | yes | Recipient in E.164. |
text |
string | yes | 1–4096 characters. |
Success — 201
{ "messageId": "wamid.HBgL...", "status": "SENT", "timestamp": "2026-07-24T09:41:00.000Z" }
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
— | Invalid JSON, failed validation, or to is not E.164. |
404 |
NO_CONTACT |
No contact for this number — a session reply needs a prior inbound message. |
404 |
NO_CONVERSATION |
Contact exists but has no conversation. |
403 |
WINDOW_CLOSED |
The 24-hour window is closed — send a template to restart. |
409 |
— | No WhatsApp account connected. |
402 |
INSUFFICIENT_CREDITS |
Wallet cannot cover the message. |
502 |
— | Meta rejected the send. |
Examples
curl -X POST https://api.pingsir.com/v1/messages/session \
-H "Authorization: Bearer $PINGSIR_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+919876543210", "text": "Thanks Priya — your order ships today!" }'
const res = await fetch("https://api.pingsir.com/v1/messages/session", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PINGSIR_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ to: "+919876543210", text: "Thanks Priya — your order ships today!" }),
});
console.log((await res.json()).status);
res = requests.post(
"https://api.pingsir.com/v1/messages/session",
headers={"Authorization": f"Bearer {os.environ['PINGSIR_KEY']}"},
json={"to": "+919876543210", "text": "Thanks Priya — your order ships today!"},
)
print(res.json()["status"])
Common integration mistakes
WINDOW_CLOSEDmeans the 24-hour window has lapsed. Don't retry the text — send a template viaPOST /messagesinstead.NO_CONTACT/NO_CONVERSATIONmean this number has never messaged you. You cannot open a conversation with a free-form message; start with a template.
POST /messages/media — send session media
Send an image, video, or document. This is a session-reply endpoint: it requires an open 24-hour window. Two ways to supply the file.
Option A — JSON, from your Media Library (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | yes | Recipient in E.164. |
mediaId |
string | yes | Id of a file already in your Media Library. |
caption |
string | no | Up to 1024 characters. |
filename |
string | no | Up to 200 characters; shown for documents. |
Option B — multipart upload (multipart/form-data)
| Field | Type | Required | Notes |
|---|---|---|---|
to |
string | yes | Recipient in E.164. |
file |
file | yes | The file to send. Also saved to your Library and counts toward storage quota. |
caption |
string | no | Up to 1024 characters. |
filename |
string | no | Overrides the uploaded file's name. |
Success — 201
{ "messageId": "wamid.HBgL...", "status": "SENT", "timestamp": "2026-07-24T09:41:00.000Z" }
Errors
| Status | Code | Meaning |
|---|---|---|
400 |
— | Malformed body, or missing to / file / mediaId. |
413 |
FILE_TOO_LARGE |
Upload exceeds the maximum size. |
404 |
CONTACT_NOT_FOUND |
No contact with that number. |
409 |
SESSION_EXPIRED |
No open 24-hour session — send a template instead. |
404 |
MEDIA_NOT_FOUND |
mediaId does not belong to this workspace. |
409 |
MEDIA_UNAVAILABLE |
The file is missing from storage or predates local storage. |
409 |
NOT_CONNECTED |
No WhatsApp account connected. |
402 |
INSUFFICIENT_CREDITS |
Wallet cannot cover the message. |
502 |
— | Meta rejected the send. |
Examples
# From a Library file
curl -X POST https://api.pingsir.com/v1/messages/media \
-H "Authorization: Bearer $PINGSIR_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+919876543210", "mediaId": "clib_9f2a...", "caption": "Your invoice" }'
# Direct upload
curl -X POST https://api.pingsir.com/v1/messages/media \
-H "Authorization: Bearer $PINGSIR_KEY" \
-F "to=+919876543210" \
-F "caption=Your invoice" \
-F "file=@invoice.pdf"
// Direct upload with FormData (Node 18+)
import { readFile } from "node:fs/promises";
const form = new FormData();
form.set("to", "+919876543210");
form.set("caption", "Your invoice");
form.set("file", new Blob([await readFile("invoice.pdf")], { type: "application/pdf" }), "invoice.pdf");
const res = await fetch("https://api.pingsir.com/v1/messages/media", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.PINGSIR_KEY}` },
body: form,
});
console.log((await res.json()).messageId);
# Direct upload
with open("invoice.pdf", "rb") as f:
res = requests.post(
"https://api.pingsir.com/v1/messages/media",
headers={"Authorization": f"Bearer {os.environ['PINGSIR_KEY']}"},
data={"to": "+919876543210", "caption": "Your invoice"},
files={"file": ("invoice.pdf", f, "application/pdf")},
)
print(res.json()["messageId"])
Common integration mistakes
- Sending outside the 24-hour window returns
SESSION_EXPIRED. Media cannot re-open a conversation — send a template withPOST /messagesfirst.- Don't set
Content-Typemanually for multipart uploads; let your HTTP client set the boundary.- A multipart upload also stores the file in your Library and counts against your storage quota.
Contacts API
GET /contacts — list contacts
Cursor-paginated, 50 per page, newest first.
Query parameters
| Param | Type | Notes |
|---|---|---|
cursor |
string | nextCursor from the previous page. Omit for the first page. |
tag |
string | Filter to contacts carrying this tag name. |
optIn |
string | OPTED_IN, OPTED_OUT, or PENDING. |
Success — 200
{
"contacts": [
{
"phone": "+919876543210",
"name": "Priya Sharma",
"email": "priya@example.com",
"optIn": "OPTED_IN",
"tags": ["vip", "mumbai"],
"createdAt": "2026-07-01T10:00:00.000Z"
}
],
"nextCursor": "clc_8a1b..."
}
nextCursor is null on the last page.
curl "https://api.pingsir.com/v1/contacts?optIn=OPTED_IN&tag=vip" \
-H "Authorization: Bearer $PINGSIR_KEY"
Common integration mistakes
- Pagination is cursor-based — pass
nextCursorback ascursor. There is no page/offset parameter.
POST /contacts — create a contact
Request body (application/json)
| Field | Type | Required | Notes |
|---|---|---|---|
phone |
string | yes | E.164. |
name |
string | yes | 1–120 characters. |
email |
string | no | Valid email. |
optIn |
string | no | OPTED_IN (default), OPTED_OUT, or PENDING. |
customFields |
object | no | String-to-string map of custom attributes. |
Success — 201 { "contact": { … } } (same contact shape as the list).
Errors — 400 invalid input / non-E.164 phone; 409 a contact with this phone already exists.
curl -X POST https://api.pingsir.com/v1/contacts \
-H "Authorization: Bearer $PINGSIR_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+919876543210", "name": "Priya Sharma", "email": "priya@example.com", "optIn": "OPTED_IN" }'
res = requests.post(
"https://api.pingsir.com/v1/contacts",
headers={"Authorization": f"Bearer {os.environ['PINGSIR_KEY']}"},
json={"phone": "+919876543210", "name": "Priya Sharma", "optIn": "OPTED_IN"},
)
print(res.status_code, res.json())
Common integration mistakes
- Opted-out contacts are always excluded from broadcasts. Setting
optIntoOPTED_OUThere suppresses future marketing to them.
GET /contacts/{phone} — fetch one contact
{phone} is the E.164 number (URL-encode the + as %2B, or pass it raw where your client allows).
Success — 200 { "contact": { … } }. Errors — 400 invalid phone; 404 not found.
curl "https://api.pingsir.com/v1/contacts/%2B919876543210" \
-H "Authorization: Bearer $PINGSIR_KEY"
PATCH /contacts/{phone} — update a contact
Send only the fields you want to change.
Request body (application/json)
| Field | Type | Notes |
|---|---|---|
name |
string | 1–120 characters. |
email |
string | null | Valid email, or null to clear. |
optIn |
string | OPTED_IN, OPTED_OUT, or PENDING. Setting OPTED_OUT stamps the opt-out time. |
customFields |
object | Replaces the custom-fields map. |
Success — 200 { "contact": { … } }. Errors — 400 invalid input; 404 not found.
curl -X PATCH https://api.pingsir.com/v1/contacts/%2B919876543210 \
-H "Authorization: Bearer $PINGSIR_KEY" \
-H "Content-Type: application/json" \
-d '{ "optIn": "OPTED_OUT" }'
Common integration mistakes
PATCHis a partial update. Omitted fields are left untouched — butcustomFields, when sent, replaces the whole map rather than merging.
Analytics API
GET /analytics — outbound delivery stats
Aggregate outbound message counts and rates for a date range. Defaults to the last 7 days.
Query parameters
| Param | Type | Notes |
|---|---|---|
from |
string | Start date (ISO or YYYY-MM-DD). Defaults to 7 days ago. |
to |
string | End date. Defaults to now. |
Success — 200
{
"period": { "from": "2026-07-17T00:00:00.000Z", "to": "2026-07-24T23:59:59.999Z" },
"sent": 1240,
"delivered": 1198,
"read": 903,
"failed": 12,
"deliveredRate": 0.9661,
"readRate": 0.7538
}
deliveredRate is delivered / sent; readRate is read / delivered. Both are 0 when the denominator is 0.
curl "https://api.pingsir.com/v1/analytics?from=2026-07-01&to=2026-07-24" \
-H "Authorization: Bearer $PINGSIR_KEY"
Common integration mistakes
- Counts cover outbound messages only. Inbound volume is not reported here.
GET /media/{path} — fetch inbound media
Download a file attached to an inbound message. The mediaUrl in a message.received webhook points here.
- Auth: Bearer API key or a Dashboard session cookie. Unlike the rest of the API, this endpoint is not Growth-gated — a delivered webhook must stay fetchable even after a downgrade. Account-status and rate limits still apply.
- Path: always begins with your own workspace (tenant) id; you can only read your own files.
Success — 200, the raw file bytes. Content-Type reflects the file; response is private, max-age=3600 and never CDN-cached.
Errors — 401/403 auth; 403 on any cross-workspace or traversal attempt; 404 if the file is gone.
curl -L "https://api.pingsir.com/api/media/<tenantId>/inbound/2026/07/abc123.jpg" \
-H "Authorization: Bearer $PINGSIR_KEY" \
--output inbound.jpg
Common integration mistakes
- Use the exact
mediaUrlfrom the webhook — don't rebuild the path by hand.- Inbound media is retained for a limited period; download and store anything you need to keep.
Webhooks
Webhooks push events from Pingsir to your server as they happen — the inverse of the REST calls above. Configure endpoints in Dashboard → Settings → Integrations → Webhooks: add a URL, choose which events it receives, and Pingsir generates a signing secret for it.
Envelope
Every delivery is an HTTP POST with this JSON body:
{
"event": "message.received",
"sent_at": "2026-07-24T09:41:00.000Z",
"data": { }
}
And these headers:
| Header | Value |
|---|---|
X-Pingsir-Event |
The event name, e.g. message.received. |
X-Pingsir-Signature |
HMAC-SHA256 of the raw request body, hex-encoded, keyed with the endpoint secret. |
X-Pingsir-Timestamp |
Unix time (seconds) when the delivery was sent. |
Verifying the signature
Compute HMAC-SHA256 over the exact raw body with your endpoint's secret and compare (constant-time) to X-Pingsir-Signature.
import crypto from "crypto";
function verify(rawBody, signature, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hmac, hashlib
def verify(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
Verify against the raw, unparsed body. Re-serialising the JSON first will change the bytes and break the signature.
Events
| Event | Fires when | data |
|---|---|---|
message.received |
An inbound message arrives (media events fire once the file is downloaded). | See below. |
message.delivered |
An outbound message is delivered. | { "messageId", "status" } |
message.read |
An outbound message is read. | { "messageId", "status" } |
message.failed |
An outbound message fails. | { "messageId", "status", "reason" } |
contact.opted_out |
A contact opts out. | { "contactId", "phone" } |
conversation.assigned |
A conversation is assigned to an agent. | { "conversationId", "assignedToId" } |
conversation.resolved |
A conversation is resolved (or reopened). | { "conversationId", "status" } |
message.received data
{
"messageId": "wamid.HBgL...",
"from": "+919876543210",
"conversationId": "clc_8a1b...",
"type": "IMAGE",
"text": null,
"caption": "Here's the photo",
"mediaUrl": "https://pingsir.com/api/media/<tenantId>/inbound/2026/07/abc123.jpg",
"mediaName": "photo.jpg",
"mediaMimeType": "image/jpeg",
"mediaError": null
}
- For text messages,
textcarries the body andcaption,mediaUrl,mediaName,mediaMimeTypearenull. - For media messages,
textisnull, the body (if any) is incaption, andmediaUrlis an authenticated link — fetch it with your API key (seeGET /media/{path}). - If a file couldn't be downloaded,
mediaUrlisnullandmediaErrorholds a coarse code:MEDIA_DOWNLOAD_FAILED,MEDIA_STORE_FAILED, orMEDIA_UNAVAILABLE.
Responding
Return any 2xx to acknowledge. A non-2xx, a timeout (10 seconds), or a connection failure counts as a failed delivery.
Retry policy
A failed delivery is retried 3 times with increasing backoff, then abandoned:
| Attempt | When |
|---|---|
| Initial | Immediately on the event. |
| Retry 1 | ~5 minutes later. |
| Retry 2 | ~30 minutes later. |
| Retry 3 | ~2 hours later. |
If all three retries fail, the delivery is marked dead and not retried again. Every attempt is recorded in Settings → Integrations → Webhooks with its status code and response. Because a delivery can be retried, your handler should be idempotent — dedupe on messageId (or the relevant id in data).
Testing
There is no test-fire mechanism yet — you cannot send yourself a synthetic event from the Dashboard. To exercise an endpoint today, trigger a real event (send a test message to your WABA number, or opt a test contact out). A manual "send test event" button is planned.
Error codes
Errors return a JSON body of { "error": "<message>", "code": "<CODE>" } (validation failures also include fieldErrors). Some 400s carry no code.
| Code | HTTP | Trigger | What to do |
|---|---|---|---|
INSUFFICIENT_CREDITS |
402 | Wallet can't cover the message. | Top up credits, then retry. |
WINDOW_CLOSED |
403 | 24h session lapsed on a /messages/session send. |
Send a template with POST /messages to reopen. |
SESSION_EXPIRED |
409 | 24h session lapsed on a /messages/media send. |
Send a template first, then the media. |
NO_CONTACT |
404 | Session reply to a number that never messaged you. | Start with a template; sessions need a prior inbound. |
NO_CONVERSATION |
404 | Contact exists but has no conversation. | Start with a template. |
CONTACT_NOT_FOUND |
404 | Media send to an unknown number. | Verify the number; ensure they've messaged you. |
MEDIA_NOT_FOUND |
404 | mediaId isn't a file in your workspace. |
Use a valid Library file id, or upload via multipart. |
MEDIA_UNAVAILABLE |
409 | Library file missing from storage / pre-migration. | Re-upload the file. Also a mediaError on inbound media that couldn't be fetched. |
MEDIA_DOWNLOAD_FAILED |
— | Inbound file couldn't be downloaded from Meta. | Webhook mediaError only; the message text still arrived. |
MEDIA_STORE_FAILED |
— | Inbound file downloaded but couldn't be stored. | Webhook mediaError only. |
FILE_TOO_LARGE |
413 | Upload exceeds the size limit. | Send a smaller file. |
NOT_CONNECTED |
409 | No WhatsApp account connected to the workspace. | Connect a WABA in the Dashboard. |
PLAN_LOCKED |
403 | Key's plan doesn't include API access. | Upgrade to Growth or Pro. |
ACCOUNT_INACTIVE |
403 | Workspace suspended or deleted. | Contact support. |
RATE_LIMITED |
429 | Over 100 requests/min for the key. | Back off and retry. |
Changelog
- 2026-07-24 — Initial public docs release.