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.

55 endpoints · OpenAPI 3 · base URL https://developer.wasparks.com

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.

  1. Create a test key

    In WaSparks, go to Developer → API keys and create a TEST key. It starts wsk_test_ and is shown once.

  2. Send a message

    POST /meta/whatsapp/{version}/{phoneNumberId}/messages with your key in X-API-Key and an Idempotency-Key.

  3. Get a 202

    You get our msg_ id and message_status: accepted. Queued, not yet delivered.

  4. Watch it land

    Register a webhook for message.sent, or poll GET /v1/messages/{id}. A test send shows a DRYRUN- 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-Key and 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 a DRYRUN- wamid and a synthesised receipt.

  • Signed, replay-safe webhooks

    X-WaSparks-Signature carries 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:manage and 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_ref to a send and it is echoed in every webhook about that message.

  • Cost before you send

    POST /v1/estimates prices 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 wamid
  • message.receivedinbound, with media
  • template.approvedor template.rejected
  • campaign.pausedwith the reason
  • customer.connectedfor partners
  • *everything; GET /v1/webhooks/events lists 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.

Open the API reference

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.

  1. Register a customer. POST /v1/customers

  2. Send a setup link. They connect their own number through Meta’s Embedded Signup.

  3. Hear it connect. A redirect to your successUrl and a customer.connected webhook, in case they close the tab.

  4. Act for them. Name the customer in X-Tenant-Id and 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.