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.

Success201

{ "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

  • variables is an object keyed by position ({"1":"…"}), not an array. An array will not map correctly.
  • templateName must 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.

Success201

{ "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_CLOSED means the 24-hour window has lapsed. Don't retry the text — send a template via POST /messages instead.
  • NO_CONTACT / NO_CONVERSATION mean 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.

Success201

{ "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 with POST /messages first.
  • Don't set Content-Type manually 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.

Success200

{
  "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 nextCursor back as cursor. 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.

Success201 { "contact": { … } } (same contact shape as the list).

Errors400 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 optIn to OPTED_OUT here 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).

Success200 { "contact": { … } }. Errors400 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.

Success200 { "contact": { … } }. Errors400 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

  • PATCH is a partial update. Omitted fields are left untouched — but customFields, 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.

Success200

{
  "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.

Success200, the raw file bytes. Content-Type reflects the file; response is private, max-age=3600 and never CDN-cached.

Errors401/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 mediaUrl from 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, text carries the body and caption, mediaUrl, mediaName, mediaMimeType are null.
  • For media messages, text is null, the body (if any) is in caption, and mediaUrl is an authenticated link — fetch it with your API key (see GET /media/{path}).
  • If a file couldn't be downloaded, mediaUrl is null and mediaError holds a coarse code: MEDIA_DOWNLOAD_FAILED, MEDIA_STORE_FAILED, or MEDIA_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.