WhatsApp, as an API you’d want to build on.
Send, receive and track WhatsApp from your own stack. Honest status codes, idempotent retries, signed webhooks and test keys that never reach a real phone.
Send a template
URL="https://developer.wasparks.com/meta/whatsapp/$META_VERSION/$PHONE_NUMBER_ID/messages"
curl -X POST "$URL" \
-H "X-API-Key: $WASPARKS_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"messaging_product": "whatsapp",
"to": "919845123456",
"type": "template",
"template": { "name": "order_shipped", "language": { "code": "en" } },
"client_ref": "order-4521"
}'
Send a template · fetch
const res = await fetch(
`https://developer.wasparks.com/meta/whatsapp/${version}/${phoneNumberId}/messages`,
{
method: "POST",
headers: {
"X-API-Key": process.env.WASPARKS_KEY,
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({
messaging_product: "whatsapp",
to: "919845123456",
type: "template",
template: { name: "order_shipped", language: { code: "en" } },
client_ref: "order-4521",
}),
},
);
// 202 Accepted: queued, not yet delivered
const { messages } = await res.json(); // messages[0].id → "msg_…"
Send a template · requests
import os, uuid, requests
res = requests.post(
f"https://developer.wasparks.com/meta/whatsapp/{version}/{phone_number_id}/messages",
headers={
"X-API-Key": os.environ["WASPARKS_KEY"],
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"messaging_product": "whatsapp",
"to": "919845123456",
"type": "template",
"template": {"name": "order_shipped", "language": {"code": "en"}},
"client_ref": "order-4521",
},
)
assert res.status_code == 202 # queued, not yet delivered
msg_id = res.json()["messages"][0]["id"] # "msg_…"
What comes back
HTTP/1.1 202 Accepted
X-RateLimit-Limit: …
X-RateLimit-Remaining: …
X-RateLimit-Reset: …
{
"messaging_product": "whatsapp",
"contacts": [{ "input": "919845123456", "wa_id": "919845123456" }],
"messages": [{ "id": "msg_0192f6c1-…", "message_status": "accepted" }]
}
Your first message in four steps.
Start with a test key. Nothing reaches a real phone until you switch to a live one.
Create a test key
In WaSparks, go to Developer → API keys and create a
TESTkey. It startswsk_test_and is shown once.Send a message
POST /meta/whatsapp/{version}/{phoneNumberId}/messageswith your key inX-API-Keyand anIdempotency-Key.Get a 202
You get our
msg_id andmessage_status: accepted. Queued, not yet delivered.Watch it land
Register a webhook for
message.sent, or pollGET /v1/messages/{id}. A test send shows aDRYRUN-wamid.
Know what will happen before you ship.
The same send, eight ways. Anything we can catch up front fails fast, with Meta’s own error codes, so your existing handling just works.
POST /meta/whatsapp/{version}/{phoneNumberId}/messages
You sendA template to an opted-in customer, with an Idempotency-Key.
HTTP/1.1 202 Acceptedmessage_status: accepted
Queued, not yet delivered. The Meta wamid arrives in the message.sent webhook and from GET /v1/messages/{id}.
You sendYour network dropped, so you send the same body with the same Idempotency-Key.
HTTP/1.1 202 Acceptedreplayed
The original response is replayed. Nothing is sent twice.
You sendYou reuse an Idempotency-Key with a different body.
HTTP/1.1 422 Unprocessable Entity2494010
Refused, so a bug in your retry logic can’t turn into a second message.
You sendFree text to a customer whose 24-hour window has closed.
HTTP/1.1 400 Bad Request131047
Rejected up front with Meta’s own code. Send an approved template instead.
You sendA template Meta hasn’t approved yet.
HTTP/1.1 400 Bad Request132001
Rejected before it reaches Meta, with the code your Meta integration already handles.
You sendA customer who replied STOP.
HTTP/1.1 403 Forbidden131050
The suppression is enforced for you, at the API, not just in the dashboard.
You sendYour rate limit, quota or the number’s daily tier cap is used up.
HTTP/1.1 429 Too Many Requests130429
Every response carries X-RateLimit-Limit, -Remaining and -Reset, so you can back off before you hit this.
You sendThe number’s access token is no longer valid.
HTTP/1.1 409 Conflictreconnect
A clear signal to reconnect the number, not a silent drop.
The details we sweat, so you don’t.
None of this is a promise on a slide. Each one is how the API behaves today, and it’s written down in the reference.
Honest status codes
Anything we can reject up front comes back as a synchronous 4xx with Meta’s own error code, never as a 202 that fails later.
Idempotent by design
Send an
Idempotency-Keyand a retry replays the original response. The same key with a different body is refused.Test keys that never reach a phone
A
wsk_test_key behaves identically end to end. Messages get aDRYRUN-wamid and a synthesised receipt.Signed, replay-safe webhooks
X-WaSparks-Signaturecarries a timestamp and an HMAC-SHA256 of the raw body. Reject anything older than five minutes.Deliveries you can see
The last 100 attempts for each endpoint, with the HTTP status we got and the next retry. Retry an exhausted delivery yourself.
Secrets shown once
API keys are stored as a SHA-256 hash and webhook secrets are encrypted at rest. Neither is ever shown again.
Scoped keys
Give each key only what it needs:
messages:send,templates:write,webhooks:manageand more.Media that doesn’t expire on you
Inbound media is copied to our storage on arrival.
GET /v1/media/{id}redirects to a signed URL, so Meta’s expiring links never break your app.Your reference, everywhere
Add
client_refto a send and it is echoed in every webhook about that message.Cost before you send
POST /v1/estimatesprices a send in the number’s billing currency, to Meta’s four decimals, and says what made it uncertain.One shape for every list
Every list returns
{ data, meta.next_cursor }. Changes to a shape are dated in the reference, with what to do.Rate limits in every response
Per key, per minute, with
X-RateLimit-*headers on everything, so you can pace yourself.
Webhooks you can trust.
Register an https endpoint, pick events, and verify every delivery with the secret we show you once.
message.sentwith the Meta wamidmessage.receivedinbound, with mediatemplate.approvedortemplate.rejectedcampaign.pausedwith the reasoncustomer.connectedfor partners*everything;GET /v1/webhooks/eventslists the rest
Test any endpoint with POST /v1/webhooks/{id}/test. See the last 100 attempts, and retry an exhausted one, from the API or the dashboard.
Verify X-WaSparks-Signature
import crypto from "node:crypto";
// X-WaSparks-Signature: t=<unix>,v1=<hex hmac-sha256(secret, t + "." + rawBody)>
export function verifyWaSparks(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > 300) return false; // reject anything older than five minutes
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Verify X-WaSparks-Signature
import hashlib, hmac, time
# X-WaSparks-Signature: t=<unix>,v1=<hex hmac-sha256(secret, t + "." + raw_body)>
def verify_wasparks(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1 or abs(time.time() - int(t)) > 300: # older than five minutes
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
Already on Meta’s Cloud API?
Change two lines. Your message bodies, your template names and your error handling stay as they are.
The one documented difference: a send returns 202 with our msg_ id, because it is queued. The wamid follows in the message.sent webhook.
POST https://graph.facebook.com/{version}/{phone-number-id}/messages
POST https://developer.wasparks.com/meta/whatsapp/{version}/{phoneNumberId}/messages
Authorization: Bearer <Meta access token>
Authorization: Bearer wsk_live_…
The whole API, at a glance.
55 endpoints across 12 groups, all in the reference with request and response shapes.
Messages1
Look up a message you sent.
Meta-compatible send1
Meta’s message shape, on our base URL.
Templates7
Create, submit and track templates.
Webhooks9
Endpoints, events, deliveries and retries.
Campaigns9
Send a template to a list and follow it.
Audiences7
Save a list once, send to it often.
Uploads1
A recipient CSV, as a campaign audience.
Media1
Download what customers sent you.
Costs and estimates3
Price a send; pull what Meta charged.
Account1
Your numbers, plan and usage.
API keys3
Create and revoke keys.
Customers12
Resell WhatsApp: customers, setup links, keys.
Building a platform on top?
Resell WhatsApp to your own users with one key. Each of them keeps their own number and their own data.
Register a customer.
POST /v1/customersSend a setup link. They connect their own number through Meta’s Embedded Signup.
Hear it connect. A redirect to your
successUrland acustomer.connectedwebhook, in case they close the tab.Act for them. Name the customer in
X-Tenant-Idand use the same key.
Developer questions.
Something the reference doesn’t answer? Ask us.
Is there a sandbox?
Yes. Create a wsk_test_ key. It behaves exactly like a live key but never reaches WhatsApp: messages get a DRYRUN- wamid and a synthesised delivery receipt.
Can I keep my Meta Cloud API client?
Yes. Point it at the WaSparks base URL and send your WaSparks key as Authorization: Bearer wsk_live_…. The only difference is the 202: you get our msg_ id now and the wamid in the message.sent webhook.
Where do I get an API key?
In WaSparks under Developer → API keys. The key is shown once and stored only as a hash. If you lose it, revoke it and create another.
What are the rate limits?
They are per key, per minute, and every response tells you where you stand in X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset.
How do I know a message was delivered?
Subscribe to webhooks, or call GET /v1/messages/{id}. Status moves from QUEUED to SENT, DELIVERED and READ, or FAILED with the reason.
Can I resell WhatsApp to my own users?
Yes. Register each business as a customer, send them a setup link to connect their own number, then act for any of them with one key by naming the customer in X-Tenant-Id.
Get a test key and send in minutes.
The public API and webhooks come with Spark and up. Start with the 30-day trial, then create your keys when you upgrade.