Skip to content
Developers

API documentation

A reference for the Whapify public API: base URL, how to authenticate, the shape of an error, and the message, contact, group and campaign endpoints it exposes.

Base URL: https://api.whapify.id/api/v1

Every request and response is JSON. An unknown field in the body is rejected with invalid_json.

Authentication

The public API uses one API key per tenant, not a dashboard session. Create a key on the API Keys page in the dashboard, then send it on every request in the Authorization header.

Authorization: Bearer wh_live_••••

The key is shown only once, when it is created. Store it somewhere safe; if it is lost or leaked, create a new one and revoke the old one from the dashboard.

A key is created with some of four permissions: send, read, contacts, campaigns. Leaving the field out grants all four. A key missing the permission an endpoint needs fails with forbidden, naming the one it lacks.

A key can be given an expiry when it is created, and can be revoked from the dashboard at any time. A revoked or expired key fails with the same unauthorized error as one that never existed, so a leaked key cannot be told apart from an unknown one. Revoking cannot be undone; the only way back is a new key.

Conventions

Ids are UUIDs. Timestamps use RFC3339 in UTC. Every response carries Cache-Control: no-store.

Idempotency

POST /wa/send accepts an Idempotency-Key header, kept for 24 hours. Sending the same key with the same body again returns the first response again, byte for byte, with an Idempotency-Replayed response header instead of queuing the message a second time.

Idempotency-Key: a-key-you-generate-once-per-send

Idempotency-Replayed: true   # on a repeat with the same body

The same key with a different body answers 422 validation_failed instead of replaying. A duplicate that arrives while the first request is still being handled answers 409 idempotency_in_progress. A response describing something temporary, such as account_not_connected, quota_exceeded, a 5xx, or rate_limited, is not kept, so retrying once that clears is a fresh attempt rather than a replay.

Error codes

Every failure uses the same envelope: a short code and a message that is safe to show.

CodeStatusDescription
validation_failed422One of the fields in the body failed validation.
invalid_json400The body is not valid JSON, or it carries a field the server does not recognise.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
not_found404The record is not yours, or it does not exist.
already_exists409A record with this value already exists.
invalid_state409The campaign is not in a state that allows this action.
account_not_connected409The WhatsApp account you are sending from is not connected.
idempotency_in_progress409Another request with this Idempotency-Key is still being handled.
quota_exceeded402The monthly send quota on your plan has been used up.
media_too_large413The media file is over the 20 MB limit.
storage_exceeded413The tenant's media storage is full. Delete some media first.
unsafe_url422media_url has to be a public https link.
invalid_signature401The signature on the media link does not match.
link_expired410The media link has expired. Read the message again for a fresh one.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

Rate limits

Every /wa/* request is limited to 60 requests a minute per API key; going over it answers 429 rate_limited. A second limit of 600 requests a minute per IP address sits in front of authentication, and POST /wa/validate additionally shares a 6-requests-a-minute budget per tenant with the dashboard's own WhatsApp lookups, because every call there is a round trip to WhatsApp itself.

Response headers

CodeDescription
X-RateLimit-LimitThe number of requests allowed in the current window.
X-RateLimit-RemainingThe number of requests left in the current window.
Retry-AfterSeconds to wait before trying again, sent on a 429 response.

These three headers are set on every authenticated answer. A 401 for a missing, revoked or expired key carries none of them.

WhatsApp

Endpoints for sending a message, reading history, validating numbers, listing WhatsApp groups, managing contacts, running campaigns, and fetching media. All of them authenticate with an API key.

Send a message

POST /wa/send

Accepts one text, media or location message and queues it. The answer is 202: accepted, not yet sent.

FieldTypeRequiredNotes
accountuuid | stringRequiredYour account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890.
account_iduuidOptionalAccepted as well, spelled as the dashboard's own field name for the same account.
recipientstringRequiredThe destination: an E.164 phone number without the plus sign, or a group id.
typetext | image | video | audio | document | locationRequiredtext, image, video, audio, document, or location.
bodystringOptionalThe text body. Optional for a location message.
media_iduuidOptionalA media id for a file already uploaded through the dashboard's media library. Use this or media_url, not both.
media_urlstringOptionalA public https URL to the media. Used when you are not sending a media_id.
location.latnumberOptionalLatitude, as a number.
location.lngnumberOptionalLongitude, as a number.
location.namestringOptionalA short place name shown with the pin.

Example request

curl -X POST https://api.whapify.id/api/v1/wa/send \
  -H "Authorization: Bearer wh_live_••••" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4711" \
  -d '{
    "account": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
    "recipient": "6281234567890",
    "type": "text",
    "body": "Halo dari Whapify"
  }'

Success response

202
{
  "id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
  "account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
  "campaign_id": null,
  "direction": "out",
  "wa_message_id": null,
  "chat_jid": "[email protected]",
  "recipient": "6281234567890",
  "group_name": null,
  "type": "text",
  "body": "Halo dari Whapify",
  "media": null,
  "location": null,
  "status": "queued",
  "error": null,
  "scheduled_at": null,
  "created_at": "2026-09-01T09:12:00Z",
  "sent_at": null,
  "updated_at": "2026-09-01T09:12:00Z"
}

Errors

CodeStatusDescription
validation_failed422One of the fields in the body failed validation.
invalid_json400The body is not valid JSON, or it carries a field the server does not recognise.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
not_found404The record is not yours, or it does not exist.
account_not_connected409The WhatsApp account you are sending from is not connected.
idempotency_in_progress409Another request with this Idempotency-Key is still being handled.
quota_exceeded402The monthly send quota on your plan has been used up.
media_too_large413The media file is over the 20 MB limit.
storage_exceeded413The tenant's media storage is full. Delete some media first.
unsafe_url422media_url has to be a public https link.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

List messages

GET /wa/messages

Reads the tenant's message history, newest first, with a keyset cursor.

FieldTypeRequiredNotes
accountuuid | string (query)OptionalYour account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890.
account_iduuid (query)OptionalAccepted as well, spelled as the dashboard's own field name for the same account.
directionin | out (query)Optionalin for received, out for sent.
statusstring (query)OptionalFilters by status: queued, sending, sent, delivered, read, failed, or received.
fromRFC3339 (query)OptionalOnly messages created at or after this time.
toRFC3339 (query)OptionalOnly messages created at or before this time.
cursorstring (query)OptionalThe next_cursor value from the previous page.
limitnumber (query)OptionalPage size, clamped to a server maximum.

Example request

curl "https://api.whapify.id/api/v1/wa/messages?direction=out&status=delivered&limit=20" \
  -H "Authorization: Bearer wh_live_••••"

Success response

200
{
  "messages": [
    {
      "id": "3f2a1b0c-9d8e-4f7a-8b6c-5d4e3f2a1b0c",
      "account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
      "campaign_id": null,
      "direction": "out",
      "wa_message_id": null,
      "chat_jid": "[email protected]",
      "recipient": "6281234567890",
      "group_name": null,
      "type": "text",
      "body": "Halo dari Whapify",
      "media": null,
      "location": null,
      "status": "queued",
      "error": null,
      "scheduled_at": null,
      "created_at": "2026-09-01T09:12:00Z",
      "sent_at": null,
      "updated_at": "2026-09-01T09:12:00Z"
    },
    {
      "id": "8d7c6b5a-4e3f-4a2b-9c1d-0e9f8a7b6c5d",
      "account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
      "campaign_id": null,
      "direction": "in",
      "wa_message_id": "3EB0A1B2C3D4E5F60718",
      "chat_jid": "[email protected]",
      "recipient": "[email protected]",
      "group_name": "Support Team",
      "type": "text",
      "body": "Sudah dikirim ya",
      "media": null,
      "location": null,
      "status": "received",
      "error": null,
      "scheduled_at": null,
      "created_at": "2026-09-01T09:12:00Z",
      "sent_at": null,
      "updated_at": "2026-09-01T09:12:00Z"
    }
  ],
  "next_cursor": "MjAyNi0wOS0wMVQwOToxMjowMFp8M2YyYTFiMGM"
}

The second row above came from a group. The only thing that says so is chat_jid: when it ends in @g.us the chat is a group, and recipient holds the participant who wrote the message rather than the chat itself. group_name is the group directory's name for it, and is null both for a private chat and for a group that has never been read from the device. It is never filled in with the group id, because an id is not a name.

Errors

CodeStatusDescription
validation_failed422One of the fields in the body failed validation.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
not_found404The record is not yours, or it does not exist.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

Validate numbers

POST /wa/validate

Checks whether numbers are registered on WhatsApp. A number that cannot be read as a phone number is reported as not existing rather than failing the whole batch.

FieldTypeRequiredNotes
accountuuid | stringRequiredYour account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890.
phones[]string[]RequiredUp to 50 numbers, E.164 without the plus sign. A number that cannot be read as a phone number is reported as not existing rather than failing the whole batch.

Example request

curl -X POST https://api.whapify.id/api/v1/wa/validate \
  -H "Authorization: Bearer wh_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
    "phones": ["081200000123", "6281234567891", "0812-BAD-NUMBER"]
  }'

Success response

200
{
  "results": [
    { "phone": "6281200000123", "exists": true, "jid": "[email protected]" },
    { "phone": "6281234567891", "exists": false, "jid": "" },
    { "phone": "0812-BAD-NUMBER", "exists": false, "jid": "" }
  ]
}

Errors

CodeStatusDescription
validation_failed422One of the fields in the body failed validation.
invalid_json400The body is not valid JSON, or it carries a field the server does not recognise.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
not_found404The record is not yours, or it does not exist.
account_not_connected409The WhatsApp account you are sending from is not connected.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

List groups

GET /wa/groups

Lists the groups visible to an account.

FieldTypeRequiredNotes
accountuuid | string (query)RequiredYour account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890.

Example request

curl "https://api.whapify.id/api/v1/wa/groups?account=9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d" \
  -H "Authorization: Bearer wh_live_••••"

Success response

200
{
  "groups": [
    {
      "id": "0c1b2a39-4859-4677-9685-9a4b3c2d1e0f",
      "account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
      "gid": "[email protected]",
      "name": "Support Team",
      "participants": 12,
      "cached_at": "2026-09-01T09:12:00Z"
    }
  ]
}

Errors

CodeStatusDescription
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
not_found404The record is not yours, or it does not exist.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

Start a campaign

POST /wa/campaign/start

Creates and starts a campaign in one call, to a list of recipients or a contact group. A campaign that fails to start is removed rather than left as an unwanted draft.

FieldTypeRequiredNotes
accountuuid | stringRequiredYour account's id, or its connected number in any common format: 081234567890, +62 812-3456-7890, or 6281234567890.
namestringRequiredA label for the campaign, or a name for the contact.
recipients[]string[]OptionalAn explicit list of destinations, phone numbers only. Use this or group_id, not both.
group_iduuidOptionalSends to every member of this contact group instead of a recipient list. This is a contact group's id, not a WhatsApp group's.
messagestringRequiredThe text body. Optional for a location message.
throttlenumber (ms)OptionalMilliseconds to wait between sends within the campaign, clamped to 500-60000. One number becomes both the minimum and the maximum of the pacing range.

Example request

curl -X POST https://api.whapify.id/api/v1/wa/campaign/start \
  -H "Authorization: Bearer wh_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "account": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
    "name": "Promo September",
    "recipients": ["6281234567890", "6281234567891"],
    "message": "Promo spesial bulan ini",
    "throttle": 2000
  }'

Success response

202
{
  "id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382104",
  "account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
  "name": "Promo September",
  "status": "running",
  "type": "text",
  "body": "Promo spesial bulan ini",
  "media_id": null,
  "throttle_min_ms": 2000,
  "throttle_max_ms": 2000,
  "total": 240,
  "queued": 240,
  "sent": 0,
  "delivered": 0,
  "failed": 0,
  "started_at": "2026-09-01T09:12:00Z",
  "finished_at": null,
  "created_at": "2026-09-01T09:12:00Z",
  "updated_at": "2026-09-01T09:12:00Z"
}

A campaign takes phone numbers only. A group id is refused with validation_failed, so every phone in the items list is always a number. To send to a group, use POST /wa/send with the group id as the recipient.

Errors

CodeStatusDescription
validation_failed422One of the fields in the body failed validation.
invalid_json400The body is not valid JSON, or it carries a field the server does not recognise.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
not_found404The record is not yours, or it does not exist.
invalid_state409The campaign is not in a state that allows this action.
account_not_connected409The WhatsApp account you are sending from is not connected.
quota_exceeded402The monthly send quota on your plan has been used up.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

Stop a campaign

POST /wa/campaign/stop

Stops a campaign that is running or paused. Stopping one that already finished or was already stopped answers invalid_state.

FieldTypeRequiredNotes
iduuidRequiredThe campaign's id, from the start response.

Example request

curl -X POST https://api.whapify.id/api/v1/wa/campaign/stop \
  -H "Authorization: Bearer wh_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382104"
  }'

Success response

200
{
  "id": "5e4d3c2b-1a09-4f8e-9d7c-6b5a49382104",
  "account_id": "9f1c0f8e-3b4a-4a2e-9c5d-6e7f8a9b0c1d",
  "name": "Promo September",
  "status": "stopped",
  "type": "text",
  "body": "Promo spesial bulan ini",
  "media_id": null,
  "throttle_min_ms": 2000,
  "throttle_max_ms": 2000,
  "total": 240,
  "queued": 0,
  "sent": 187,
  "delivered": 0,
  "failed": 53,
  "started_at": "2026-09-01T09:12:00Z",
  "finished_at": "2026-09-01T09:44:12Z",
  "created_at": "2026-09-01T09:12:00Z",
  "updated_at": "2026-09-01T09:12:00Z"
}

Errors

CodeStatusDescription
invalid_json400The body is not valid JSON, or it carries a field the server does not recognise.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
not_found404The record is not yours, or it does not exist.
invalid_state409The campaign is not in a state that allows this action.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

List contacts

GET /wa/contacts

Reads the tenant's contacts, filterable by name, phone or contact group, with a keyset cursor.

FieldTypeRequiredNotes
searchstring (query)OptionalFilter by name or phone, matching anywhere in the value.
group_iduuid (query)OptionalOnly contacts belonging to this contact group.
cursorstring (query)OptionalThe next_cursor value from the previous page.
limitnumber (query)OptionalPage size, clamped to a server maximum.

Example request

curl "https://api.whapify.id/api/v1/wa/contacts?search=budi&limit=20" \
  -H "Authorization: Bearer wh_live_••••"

Success response

200
{
  "contacts": [
    {
      "id": "c3530767-8f0a-4b88-8df4-b07d423f9f7a",
      "name": "Budi",
      "phone": "6281234567890",
      "tags": [],
      "notes": null,
      "groups": [],
      "created_at": "2026-09-01T09:12:00Z",
      "updated_at": "2026-09-01T09:12:00Z"
    }
  ],
  "next_cursor": ""
}

Errors

CodeStatusDescription
validation_failed422One of the fields in the body failed validation.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

Create a contact

POST /wa/contacts

Adds one contact. A phone number the tenant already has fails with already_exists.

FieldTypeRequiredNotes
namestringRequiredA label for the campaign, or a name for the contact.
phonestringRequiredAn Indonesian WhatsApp number in any common format. Stored normalised to digits only, starting with 62.
tags[]string[]OptionalFree-form labels for the contact.
notesstringOptionalFree-form notes about the contact.

Example request

curl -X POST https://api.whapify.id/api/v1/wa/contacts \
  -H "Authorization: Bearer wh_live_••••" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Budi",
    "phone": "0812-3456-7890",
    "tags": ["vip"]
  }'

Success response

201
{
  "id": "c3530767-8f0a-4b88-8df4-b07d423f9f7a",
  "name": "Budi",
  "phone": "6281234567890",
  "tags": ["vip"],
  "notes": null,
  "groups": [],
  "created_at": "2026-09-01T09:12:00Z",
  "updated_at": "2026-09-01T09:12:00Z"
}

Errors

CodeStatusDescription
validation_failed422One of the fields in the body failed validation.
invalid_json400The body is not valid JSON, or it carries a field the server does not recognise.
unauthorized401The Authorization header is missing, or the API key is unknown, revoked or expired.
forbidden403The API key does not have the permission this endpoint needs.
account_suspended403The tenant that owns this API key is suspended.
already_exists409A record with this value already exists.
rate_limited429Too many requests from this client in one minute.
internal500Something failed on the server. Try again.

Fetch media

GET /media/{id}

Reads the bytes of a message's attachment through a signed link. It takes no Authorization header.

FieldTypeRequiredNotes
expnumber (query)RequiredThe link's expiry, from a message's media block.
sigstring (query)RequiredThe link's signature, from a message's media block.

Example request

curl "https://api.whapify.id/api/v1/media/7c6b5a49-3821-4d0e-9f1a-2b3c4d5e6f70?exp=1798000325&sig=5f2b1c9e" \
  --output katalog.png

Success response

200
Content-Type: image/png
Content-Disposition: inline; filename="7c6b5a49-3821-4d0e-9f1a-2b3c4d5e6f70.png"
X-Content-Type-Options: nosniff
Cache-Control: no-store

Errors

CodeStatusDescription
not_found404The record is not yours, or it does not exist.
invalid_signature401The signature on the media link does not match.
link_expired410The media link has expired. Read the message again for a fresh one.
internal500Something failed on the server. Try again.

POST /wa/send in Node and PHP

The same request as the curl example above, in two more languages.

const res = await fetch("https://api.whapify.id/api/v1/wa/send", {
  method: "POST",
  headers: {
    Authorization: "Bearer wh_live_••••",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    account: "6281234500000",
    recipient: "6281234567890",
    type: "text",
    body: "Halo dari Whapify",
  }),
});
console.log(await res.json()); // 202: { id, status: "queued", ... }

Webhooks

Register a URL per event type from the dashboard's Webhooks page. Whapify posts the envelope below to it whenever a subscribed event happens, signed with the secret shown once when the webhook is created.

Events

  • message.received: A WhatsApp message arrived on a linked account.
  • message.status: A sent message changed status: sent, delivered, read, or failed.
  • account.status: A linked account's connection status changed.
  • campaign.progress: A running campaign's counts changed.

Envelope

Every delivery is a POST with this body:

{
  "id": "evt_01JA4C6E8G0K2M4P6R8T0V2X4Z",
  "type": "message.status",
  "created_at": "2026-09-01T09:12:05Z",
  "data": {
    "id": "msg_01J8X9Q7Z3K2N4P6R8T0V2W4Y6",
    "status": "delivered"
  }
}

Headers

CodeDescription
X-SignatureHex HMAC-SHA256 of the raw body, keyed with the endpoint secret.
X-TimestampWhen the delivery was sent, as a Unix timestamp in seconds.
X-Event-IdA stable id for this delivery. Use it to drop duplicates from a retry.

A sample delivery

What Whapify sends to your endpoint, not what you send to Whapify.

curl -X POST https://your-app.example.com/webhooks/whapify \
  -H "Content-Type: application/json" \
  -H "X-Signature: sha256=5f2b1c9e8a3d4f6b7c8e9a0d1f2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b" \
  -H "X-Timestamp: 1798000325" \
  -H "X-Event-Id: evt_01JA4C6E8G0K2M4P6R8T0V2X4Z" \
  -d '{ "id": "evt_01JA4C6E8G0K2M4P6R8T0V2X4Z", "type": "message.status", "created_at": "2026-09-01T09:12:05Z", "data": { "id": "msg_01J8X9Q7Z3K2N4P6R8T0V2W4Y6", "status": "delivered" } }'

Retries

A delivery that does not answer with a 2xx status is retried up to 5 times, with a growing wait between attempts.

Verifying a delivery

Recompute the signature from the raw body and compare it to X-Signature in constant time, as in the sample below.

import { createHmac, timingSafeEqual } from "node:crypto";

// Every delivery carries the X-Signature header: "sha256=" plus the hex
// HMAC-SHA256 of the raw body, keyed with the endpoint secret.
export function verify(rawBody, signature, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(signature ?? "", "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}