API documentation
Send WhatsApp messages, run campaigns, verify phone numbers with WhatsApp OTP, and manage contacts and templates programmatically. Authenticate every request with a bearer token — create an account to get your keys.
Getting started
All requests go to the base URL below and authenticate with a bearer token.
Example request
curl -X POST https://blasty.my/api/v1/messages/send \
-H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"phone":"0123456789","body":"Hello from the API!"}'
messages/send, messages/bulk, campaigns, otp) require a verified email on the key's owner. Each delivered message consumes one credit.Keys pinned to a number
device_id is optional; passing a different one returns 422. It only sees that number's messages, campaigns, OTPs and connection, while contacts and templates stay shared across your account. You can move a key to another number without changing your app, and removing the number revokes the key.Account
/api/v1/user
Who the key acts for
The key's user (id, name, email), the account's credits balance and the plan in force now (name, slug, and expires_at, null when it never expires). A quick way to check a key works and that there are credits before a big send.
Response
{
"data": {
"id": 7,
"name": "Ali",
"email": "ali@example.com",
"credits": 1250,
"plan": {
"name": "Pro",
"slug": "pro",
"expires_at": "2026-11-27T00:00:00+00:00"
}
}
}
Connection
/api/v1/connection/status
Connection status
Report the WhatsApp connection state as devices: every device with its driver (wwebjs QR device or meta Cloud API number) and live status. A pinned key reports only its own number.
/api/v1/connection/qr
Login QR code
Return a device login QR (data URL) for linking a WhatsApp account. Pass ?device=<id>; defaults to the number the key is pinned to, else the resolved default device. Only for QR (wwebjs) devices.
Messages
/api/v1/messages
List messages
Paginated message log, newest first (a pinned key sees only the messages of its own number). Filter with ?status=, ?campaign_id=, and ?per_page= (default 25). Each message has a channel: whatsapp, or messenger / instagram for chats on a connected Facebook Page or Instagram account, whose recipient is a Meta id (fb:… / ig:…), not a phone number.
/api/v1/messages/{id}
Get message
Fetch the current status of a single message (pending, sent, failed, or cancelled when it was never sent) and its direction (out/in). Delivery receipts fill delivered_at and read_at when the recipient allows them (privacy settings); replied_at is set when the recipient replied to a campaign message; an inbound message that came from a Meta click-to-WhatsApp ad carries its referral (ad id, headline, ctwa_clid); one received in a WhatsApp group has the group id as recipient and who wrote it as author (phone, name), else author is null. Meta Cloud API messages also report meta_pricing (whether Meta charged for it, and the pricing category).
/api/v1/messages/send
Send a message
Queue a single message. Requires a verified email. Phone must parse as a Malaysian number. Pass save_contact: true or an external_id to also upsert the contact, with its name and attributes (merge fields). Attach a file with media_url (public https; optional media_filename, media_mime; needs the media plan feature): body becomes its caption and can be left out. Pass device_id to send from a specific device (e.g. the Meta Cloud API number; free text only reaches contacts who messaged in the last 24h); a pinned key always sends from its own number. Returns 202 Accepted.
Request body
{
"phone": "0123456789",
"body": "Hello from the API!",
"name": "Ali",
"external_id": "crm-42",
"save_contact": true
}
/api/v1/messages/bulk
Send in bulk
Queue up to 1000 messages at once. Requires a verified email and the campaigns plan feature. Each item takes phone and body, plus optional name, external_id and attributes (an item with a name or external_id also upserts its contact) and its own media_url / media_filename / media_mime attachment (then body is optional; needs the media plan feature). Credits are charged up front for every valid recipient; numbers whose contact opted out are rejected with reason: opted_out and cost nothing. Returns per-item queued and rejected arrays. Optional top-level device_id sends them all from one device.
Request body
{
"messages": [
{ "phone": "0123456789", "body": "Hi Ali" },
{ "phone": "0198887777", "body": "Hi Sara", "name": "Sara" }
]
}
Campaigns
/api/v1/campaigns
List campaigns
Paginated campaign log, newest first (a pinned key sees only the campaigns of its own number). Use ?per_page= to change page size.
/api/v1/campaigns/{id}/cancel
Cancel or stop a campaign
Call off a scheduled campaign before it sends (its reserved credits are refunded), or stop a sending/paused one: messages already sent stay sent, everything still waiting is cancelled and refunded. Either way the campaign ends cancelled. Returns 409 once it has finished.
/api/v1/campaigns/{id}/pause
Pause a campaign
Hold a sending campaign: queued messages wait (still pending) until it is resumed. Returns 409 unless it is sending.
/api/v1/campaigns/{id}/resume
Resume a campaign
Carry on with a paused campaign at the usual pace. Returns 409 unless it is paused.
/api/v1/campaigns/{id}
Get campaign
Fetch a single campaign with its template and a stats funnel: total, sent, failed, cancelled, delivered, read, replied, clicked (tracked links), plus variants for an A/B test (each variant's funnel). A WhatsApp group-chat campaign (targets_group_chats: true) also has groups, one entry per group chat: message_id, group (its id), name, status, sent_at, members, reached and read (members, from WhatsApp's own message info: refreshed every few minutes while you poll, for 7 days after sending; receipts_synced_at says when, and members is null until the first read), replies and repliers (members' messages in the group within 72h, until the next campaign message to it), clicks and unique_clicks; group_totals adds them up, with replied_groups. Its funnel's delivered/read count a group only once every member got/read it.
Response
{
"id": 31,
"targets_group_chats": true,
"…": "…",
"stats": {
"total": 2, "sent": 2, "…": "…",
"groups": [
{
"message_id": 5120,
"group": "120363000000000001@g.us",
"name": "Agents Johor",
"status": "sent",
"sent_at": "2026-10-27T01:00:04+00:00",
"members": 12,
"reached": 9,
"read": 6,
"replies": 4,
"repliers": 3,
"clicks": 3,
"unique_clicks": 3,
"receipts_synced_at": "2026-10-27T03:58:10+00:00"
}
],
"group_totals": { "groups": 2, "sent": 2, "members": 60, "reached": 40, "read": 23, "replied_groups": 2, "replies": 6, "repliers": 5, "clicks": 7, "unique_clicks": 6 }
}
}
/api/v1/campaigns
Create campaign
Create and immediately dispatch a campaign, or pass scheduled_at (ISO 8601; without an offset it is Malaysia time, up to 30 days ahead) to send it later: status scheduled, and one credit per recipient is reserved now. Requires a verified email. Provide recipients via contact_group_id, contact_ids, inline recipients (phone, name, attributes; upserted as contacts), and/or retarget ({campaign_id, stage}: the people at one stage of an earlier campaign, stage one of read_not_replied, delivered_not_read, not_delivered, failed, replied, clicked; the new campaign reports parent_campaign_id). Body comes from message_template_id or a free-text body. Pass device_id to send from a specific device; on the Meta Cloud API number the template must be mapped to an approved Meta template. Pass track_links: true (click tracking plan feature) to rewrite links in the message into per-recipient tracked links; with a Meta template whose link button is tracked, link_url is where that button leads. Attach a file to every message with media_url (public https; optional media_filename, media_mime; needs the media plan feature): the message becomes its caption, and on a Meta number it fills the image/video/document header of the template. To post in WhatsApp group chats instead of messaging people, pass wa_groups: up to 256 group ids from GET /whatsapp-groups for the sending number (a QR number only; not together with the other recipient fields). The number must still be in each group, or it is 422. Each group is one recipient and one credit, {name} is the group's name, and the campaign reports targets_group_chats: true.
Request body
{
"name": "June promo",
"body": "Salam {{name}}, here is your offer!",
"contact_group_id": 3,
"recipients": [
{ "phone": "0123456789", "name": "Ali" }
]
}
Groups
/api/v1/contact-groups
List contact groups
Your contact groups and smart segments, by name: what contact_group_id on a campaign points at. type is static (hand-picked) or dynamic (a segment, matched by its rules when used). members_count counts WhatsApp people in it, reachable_count those a campaign would reach (not opted out). Use ?per_page= to change page size (default 25).
Response
{
"data": [
{
"id": 3,
"name": "VIP",
"type": "static",
"members_count": 120,
"reachable_count": 117,
"created_at": "2026-10-01T02:15:00+00:00"
}
],
"links": { "…": "…" },
"meta": { "current_page": 1, "total": 1, "…": "…" }
}
/api/v1/whatsapp-groups
List WhatsApp groups
The WhatsApp group chats a QR (wwebjs) number can post in, read live from it: what wa_groups on a campaign takes. Pass ?device_id= (defaults to the number the key is pinned to, else the default number). The list is cached for 2 minutes; ?fresh=1 reads it again. participants is null when WhatsApp hasn't loaded the group's members. Answers 422 with the reason when the number is a Meta number, isn't connected, or the read times out.
Response
{
"device_id": 2,
"groups": [
{ "id": "120363000000000001@g.us", "name": "Agents Johor", "participants": 12 },
{ "id": "120363000000000002@g.us", "name": "Raya buyers 2026", "participants": 48 }
]
}
Sequences
/api/v1/sequences
List sequences
Drip sequences built on the dashboard, with steps_count and active_count (people in progress). A pinned key sees only the sequences that send from its number. Use ?per_page= to change page size (default 25).
/api/v1/sequences/{id}/enrollments
Enrol contacts
Put contact_ids and/or inline recipients (phone, name, attributes; upserted as contacts, within the contact limit of your plan) into a sequence. Requires a verified email. Each step costs 1 credit when it sends, inside the 09:00–21:00 Malaysia-time window; nothing is charged now. A contact goes through a sequence once: returns enrolled ids and skipped with a reason (already_enrolled, opted_out, group_chat, no_steps). Returns 202 Accepted.
Request body
{
"contact_ids": [12],
"recipients": [
{ "phone": "0123456789", "name": "Ali" }
]
}
/api/v1/sequences/{id}/enrollments/{contact_id}
Remove a contact
Take a contact out of a sequence; they get no further steps. Replies stop a sequence on their own when it is set to stop on reply, and opting out (STOP) always does.
OTP
OneClick verification: prove a user owns a WhatsApp number
Check a user’s WhatsApp number at sign-up, log them in without a password, or confirm a number before you trust it. Blasty sends and checks the codes from your own WhatsApp numbers, in English or Malay. Each OTP costs 1 credit, and creating one needs a verified email on the key’s owner. There are two modes: in normal mode Blasty sends the user a code, in reverse mode the user sends one to you.
Two ways to verify a number
Normal: Blasty sends the code
“We sent a code to your WhatsApp.” The user reads it and types it into your app. The classic flow, for any device.
Reverse: the user sends the code
“Verify with WhatsApp.” One tap opens WhatsApp with the message ready; the user presses Send. No typing, no number to ask for.
| Normal | Reverse | |
|---|---|---|
| Who sends the code | Blasty, to the user | The user, to your number |
| What the user does | Reads the code in WhatsApp and types it into your app | Taps a link, then Send in WhatsApp. Nothing to type |
| Your app needs first | The user’s phone number | Nothing: verified_phone tells you who sent it |
| You learn the result | Right away, from POST /otp/{id}/verify |
By polling GET /otp/{id}, or from the callback |
| The code | 6 digits, never returned by the API | 6 characters without I, O, 0 or 1, returned once when created |
| Credits | 1, when the code is sent | 1, when it is created (also pays for the ✅ reply) |
| Wrong guesses | 5 per OTP, then it locks | Not applicable |
| On a Meta (official) number | Needs your approved blasty_otp template |
Works as is: the user starts the chat |
| Best for | Web and desktop sign-ups, or when the user’s WhatsApp is on another device | One-tap sign-in on mobile, and “sign up with WhatsApp” |
See the difference
Play both sides: your app on the left, your user’s WhatsApp on the right. Nothing is sent, and the API traffic below is exactly what your server would see.
Your app
Too many wrong codes.
Your user’s WhatsApp
No messages yet
API traffic
Requests between your server and Blasty show up here.
Request body
Try it on your own phone
Normal mode, step by step
You know the user’s number. Blasty sends them a code; they type it into your app; you check it.
-
1
Start it from your server
Send the user’s number. Blasty charges 1 credit and queues the code from your number (the one the key is pinned to,
device_id, or your default). Keep theid: the code itself is never returned, so it can’t leak through your app.Request
curl -X POST https://blasty.my/api/v1/otp \ -H "Authorization: Bearer <your-api-key>" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"mode":"normal","phone":"0123456789","reference":"signup-42","callback_url":"https://your-app.example/otp-hook"}'Response 201 Created
{ "data": { "id": 1042, "mode": "normal", "status": "pending", "phone": "60123456789", "verified_phone": null, "device_id": 2, "language": "en", "reference": "signup-42", "attempts_left": 5, "expires_at": "2026-10-13T09:20:00+00:00", "verified_at": null, "created_at": "2026-10-13T09:15:00+00:00" } } -
2
The user gets the code on WhatsApp
From a QR number it reads like this (in Malay with
"language": "ms";app_nameadds a[YourApp]prefix). From a Meta number it goes out as your approvedblasty_otpauthentication template, in Meta’s wording with a Copy code button: set it up on the OTP page first.482913 is your verification code. It expires in 5 minutes. Do not share it with anyone.482913 ialah kod pengesahan anda. Sah selama 5 minit. Jangan kongsi kod ini dengan sesiapa. -
3
Check the code the user typed
Every call uses one attempt, right or wrong. The right code answers
200and the OTP isverified; anything else answers422with areason. After 5 wrong codes the OTP locks (failed) and you start a new one.Request
curl -X POST https://blasty.my/api/v1/otp/1042/verify \ -H "Authorization: Bearer <your-api-key>" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"code":"482913"}'Response 200 OK
{ "verified": true, "reason": null, "attempts_left": 4, "data": { "id": 1042, "mode": "normal", "status": "verified", "phone": "60123456789", "verified_phone": "60123456789", "device_id": 2, "language": "en", "reference": "signup-42", "attempts_left": 4, "expires_at": "2026-10-13T09:20:00+00:00", "verified_at": "2026-10-13T09:16:12+00:00", "created_at": "2026-10-13T09:15:00+00:00" } }Wrong code 422 Unprocessable
{ "verified": false, "reason": "incorrect", "attempts_left": 4, "data": { "id": 1042, "mode": "normal", "status": "pending", "phone": "60123456789", "verified_phone": null, "device_id": 2, "language": "en", "reference": "signup-42", "attempts_left": 4, "expires_at": "2026-10-13T09:20:00+00:00", "verified_at": null, "created_at": "2026-10-13T09:15:00+00:00" } } -
4
Optionally, let Blasty call you
With a
callback_urlyour server also getsotp.verified, orotp.expiredif the user never finishes. In normal mode the verify answer already tells you, so this is mostly for logging and clean-up. See Callbacks.
Reverse mode, step by step
You don’t need the number. The user sends a code to your WhatsApp number in one tap; Blasty tells you which number sent it.
-
1
Start it from your server
No phone needed. The answer adds the
code, the ready-madetext, a one-taplinkand thewhatsapp_numberto message. These four come back only in this response. 1 credit, taken now (it also pays for the ✅ reply). Already know the number? Passphoneand only a message from it counts.Request
curl -X POST https://blasty.my/api/v1/otp \ -H "Authorization: Bearer <your-api-key>" \ -H "Accept: application/json" \ -H "Content-Type: application/json" \ -d '{"mode":"reverse","reference":"login-77","callback_url":"https://your-app.example/otp-hook"}'Response 201 Created
{ "data": { "id": 1043, "mode": "reverse", "status": "pending", "phone": null, "verified_phone": null, "device_id": 2, "language": "en", "reference": "login-77", "attempts_left": null, "expires_at": "2026-10-13T09:20:00+00:00", "verified_at": null, "created_at": "2026-10-13T09:15:00+00:00" }, "code": "K7Q2MX", "text": "My verification code is K7Q2MX", "link": "https://wa.me/60111111111?text=My%20verification%20code%20is%20K7Q2MX", "whatsapp_number": "60111111111" } -
2
Show the link
On a phone, a button that opens WhatsApp with the message ready. On a computer, show the same link as a QR code to scan with the phone, plus the
textandwhatsapp_numberfor anyone who prefers to type it.HTML
<a href="https://wa.me/60111111111?text=My%20verification%20code%20is%20K7Q2MX">Verify with WhatsApp</a>
-
3
The user taps Send
Blasty finds the code anywhere in the message, in any letter case, marks the OTP
verifiedand records the sender asverified_phone. The ✅ reply is already paid for, your AI agent stays quiet, and the code is masked in your message log. A code sent from one of your own connected numbers is ignored.My verification code is K7Q2MX✅ Verified. You can go back to the app now. -
4
Find out it’s done
Poll
GET /otp/{id}from your server every 2–3 seconds while your page waits, untilstatusis no longerpending(give up atexpires_at), or wait for theotp.verifiedcallback. Then sign the user in asverified_phone.Request
curl https://blasty.my/api/v1/otp/1043 \ -H "Authorization: Bearer <your-api-key>" \ -H "Accept: application/json"
Response 200 OK
{ "data": { "id": 1043, "mode": "reverse", "status": "verified", "phone": null, "verified_phone": "60123456789", "device_id": 2, "language": "en", "reference": "login-77", "attempts_left": null, "expires_at": "2026-10-13T09:20:00+00:00", "verified_at": "2026-10-13T09:15:41+00:00", "created_at": "2026-10-13T09:15:00+00:00" } }
Reference
/api/v1/otp
Start an OTP. Needs a verified email on the key’s owner; costs 1 credit. Answers 201 with the OTP as data (plus code, text, link and whatsapp_number in reverse mode).
| Field | Type | Required | Description |
|---|---|---|---|
| mode | string | Required | normal or reverse. |
| phone | string | Normal: required Reverse: optional |
A Malaysian number in any common format (012-345 6789, +60123456789), stored as 60123456789. In reverse mode, only a message from this number counts. |
| device_id | integer | Optional | The number that sends the code (normal) or receives it (reverse). Defaults to the number the key is pinned to, else your default device. A pinned key can only use its own number (another one is a 422). |
| ttl | integer | Optional | Seconds the code stays valid: 60 to 1800. Default 300. |
| language | string | Optional | en (default) or ms: the code message, the reverse text and the ✅ reply. |
| app_name | string | Optional | Normal mode on QR numbers: up to 40 characters, shown as [YourApp] before the code. Meta numbers use the template’s wording. |
| reference | string | Optional | Up to 255 characters of your own (a user or session id), echoed in every answer and callback. |
| callback_url | string | Optional | A public https:// URL to POST otp.verified and otp.expired to. Private and local addresses are refused. |
/api/v1/otp/{id}
Read an OTP: {"data": {…}}. Its status already reads expired once expires_at has passed, before the expiry callback goes out (within a minute). Doesn’t need a verified email. A pinned key only sees OTPs on its own number.
/api/v1/otp/{id}/verify
Normal mode: check the code (string) the user typed. Answers 200 with verified: true, or 422 with verified: false and a reason from this list. Both answers carry attempts_left and the OTP as data.
| Reason | Meaning |
|---|---|
| incorrect | Wrong code. attempts_left says how many tries remain; the last wrong one locks the OTP (status: failed). |
| expired | Past expires_at. Start a new OTP. |
| locked | Too many wrong codes already (status: failed). Start a new OTP. |
| already_verified | This OTP was already verified. Each one verifies once. |
| wrong_mode | A reverse OTP: it verifies by WhatsApp message, not by a typed code. |
The OTP object
| Field | Type | Description |
|---|---|---|
| id | integer | Use it to read or verify the OTP. |
| mode | string | normal or reverse. |
| status | string | pending, verified, expired or failed (see below). |
| phone | string|null | Normal: who the code went to. Reverse: the number it is pinned to, or null. |
| verified_phone | string|null | The number that proved itself, once verified. In reverse mode, whoever sent the code. |
| device_id | integer | Your number that sent (normal) or received (reverse) the code. |
| language | string | en or ms. |
| reference | string|null | What you passed. |
| attempts_left | integer|null | Normal: codes the user can still try (each verify call uses one). Reverse: null. |
| expires_at | string | ISO 8601 (UTC). After this the OTP reads as expired. |
| verified_at | string|null | ISO 8601 (UTC), when it was verified. |
| created_at | string | ISO 8601 (UTC). |
| code, text, link, whatsapp_number | string | Reverse mode, in the create answer only (top level, next to data). Not readable later. |
Status
Only pending ever changes, and only once. Every other status is final: to try again, start a new OTP.
Errors
Errors are JSON with a message (and errors per field on a 422). Always send Accept: application/json, or some errors answer with a redirect instead.
| Status | When |
|---|---|
| 401 | The API key is missing or wrong. |
| 402 | Not enough credits. Nothing was sent or charged. |
| 403 | Your plan has no API access, the key owner’s email isn’t verified (creating only), or the number a pinned key belonged to was removed. |
| 404 | No such OTP in your account, or it belongs to another number than the one the key is pinned to. |
| 422 | Validation, with errors per field: the phone isn’t a Malaysian number; no number to send from; in reverse mode, the number isn’t connected; on a Meta number, no approved blasty_otp template in that language; the callback_url isn’t public https; a pinned key named another device_id. |
| 429 | A normal-mode code already went to this number in the last 30 seconds. The Retry-After header says when to try again. |
Callbacks
Pass a callback_url and Blasty POSTs the OTP to it when it turns verified (otp.verified) or runs out (otp.expired).
data is the OTP exactly as GET /otp/{id} returns it. Each request carries X-Blasty-Event and
X-Blasty-Signature: sha256= HMAC-SHA256 of the raw body with your signing secret
(on the OTP page; the same secret signs account webhooks).
Answer with any 2xx; anything else is tried 3 times in all (after 10, 30 and 60 seconds), so treat a repeat of the same data.id and event as one.
Example body
{
"event": "otp.verified",
"data": {
"id": 1043,
"mode": "reverse",
"status": "verified",
"phone": null,
"verified_phone": "60123456789",
"device_id": 2,
"language": "en",
"reference": "login-77",
"attempts_left": null,
"expires_at": "2026-10-13T09:20:00+00:00",
"verified_at": "2026-10-13T09:15:41+00:00",
"created_at": "2026-10-13T09:15:00+00:00"
}
}
Verifying the signature (PHP)
$body = file_get_contents('php://input');
$expected = 'sha256='.hash_hmac('sha256', $body, $signingSecret);
if (! hash_equals($expected, $_SERVER['HTTP_X_BLASTY_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
['event' => $event, 'data' => $otp] = json_decode($body, true);
if ($event === 'otp.verified') {
// $otp['reference'] is yours; $otp['verified_phone'] is the proven number.
}
Verifying the signature (Node.js)
const crypto = require('node:crypto');
// Express: app.post('/otp-hook', express.raw({ type: 'application/json' }), otpHook);
function otpHook(req, res) {
const expected = 'sha256=' + crypto.createHmac('sha256', signingSecret).update(req.body).digest('hex');
const given = req.get('X-Blasty-Signature') || '';
if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body); // 'otp.verified' | 'otp.expired'
res.sendStatus(204);
}
Good practice
- Keep the API key on your server. Your page talks to your backend, and your backend calls Blasty, in both modes.
- Never show or log a normal-mode code. The API never returns it; Blasty keeps only a hash and masks it in your message log.
-
Trust
verified_phone, not what the user typed into your form. In reverse mode it is the number that actually sent the code. -
Offer “Resend” after 30 seconds. One normal-mode code per number every 30 seconds (
429before that). Pick attlthat fits: 5 minutes by default, up to 30. -
Use
referenceto tie an OTP to your user or session; it comes back in every answer and callback. -
QR numbers and pinned phones: WhatsApp sometimes hides a sender’s number behind a private ID, so a reverse OTP pinned to a
phonemay not match on a QR number. Meta numbers always report it; or leavephoneout and checkverified_phoneyourself.
Contacts
/api/v1/contacts
List contacts
Paginated, searchable contact list. Filter with ?search= (name or phone) or ?external_id=, and set ?per_page= (default 25).
/api/v1/contacts
Create / upsert contact
Create or update a contact. Idempotent on the normalized phone number — returns 201 on create, 200 on update. attributes are merge fields ({plan} in a message), merged into what is stored; day-first dates (31/12/2026) are stored as 2026-12-31 and read day-first again in messages ({renewal_date} → 31/12/2026).
Request body
{
"phone": "0123456789",
"name": "Ali",
"external_id": "crm-42",
"attributes": { "plan": "gold" }
}
/api/v1/contacts/{id}
Get contact
Fetch a single contact.
/api/v1/contacts/{id}
Update contact
PATCH works the same. Update name, external_id, attributes (replaced as a whole; dates stored as Y-m-d), or opted_out. An opted-out contact is skipped by campaigns, sequences and bulk sends (single sends and OTPs still reach them); contacts also opt out themselves by replying STOP (and back in with START). opted_out_at and opted_out_via (keyword, api, dashboard) record the change.
/api/v1/contacts/{id}
Delete contact
Permanently delete a contact.
Templates
/api/v1/templates
List templates
Paginated template list. Pass ?active_only=1 to return only active templates, and ?per_page= to change page size (default 25).
/api/v1/templates
Create template
Create a reusable message template. A slug is generated from the name when omitted. To send it from a Meta Cloud API number, map it to a template approved on that number's WhatsApp Business account: meta_template_name, meta_language (default en) and meta_params, the merge keys that fill the Meta template's {{1}}, {{2}}… in order (up to 20, letters, digits and underscores).
Request body
{
"name": "Welcome",
"body": "Salam {{name}}, welcome aboard!",
"is_active": true,
"meta_template_name": "welcome_offer",
"meta_language": "ms",
"meta_params": ["name"]
}
/api/v1/templates/{id}
Get template
Fetch a single template.
/api/v1/templates/{id}
Update template
Update any template field, including the Meta mapping (meta_template_name, meta_language, meta_params). PATCH works the same.
/api/v1/templates/{id}
Delete template
Permanently delete a template.
Webhooks
Instead of polling, give Blasty one URL for your account on the Webhooks page. Each event you subscribe to is POSTed there as JSON. data is the message (exactly as GET /messages/{id} returns it) or the campaign with its stats.
message.sent— WhatsApp accepted an outgoing message.message.failed— An outgoing message failed for good.message.delivered— An outgoing message reached the phone.message.read— The recipient read an outgoing message.message.received— A message came in on one of your numbers.campaign.completed— A campaign finished sending.campaign.paused— A running campaign was paused (by you, or by the device health guard).campaign.resumed— A paused campaign carried on sending.campaign.stopped— A running campaign was stopped; its unsent messages were refunded.contact.opted_out— A contact opted out of marketing (e.g. replied STOP).contact.opted_in— A contact opted back in (e.g. replied START).chat.handoff— A chat was handed from the AI agent to a person.chat.assigned— A chat was assigned to a team member (or unassigned).credits.low— Your credit balance dropped below your low-credit alert.keyword.matched— A message matched a keyword auto-reply rule.device.unhealthy— A number looked like a ban risk, so its campaigns and drips were paused.link.clicked— Someone clicked a tracked link in a campaign or drip message (first click per message).
Every request carries X-Blasty-Event, X-Blasty-Delivery (the event id) and X-Blasty-Signature: sha256= HMAC-SHA256 of the raw body with your signing secret (the same one that signs OTP callbacks). Answer with any 2xx quickly; anything else is retried 3 more times over about 6 minutes, so dedupe on id. Delivered and read receipts depend on the recipient's privacy settings.
Example body
{
"id": "9b2f7c1e-4d0a-4f5e-9a51-3c8e2b7d6f10",
"event": "message.read",
"created_at": "2026-10-13T09:15:02+00:00",
"data": {
"id": 1042,
"recipient": "60123456789",
"status": "sent",
"direction": "out",
"source": "campaign",
"campaign_id": 7,
"device_id": 2,
"read_at": "2026-10-13T09:15:01+00:00",
"…": "…"
}
}
Verifying the signature (PHP)
$body = file_get_contents('php://input');
$expected = 'sha256='.hash_hmac('sha256', $body, $signingSecret);
if (! hash_equals($expected, $_SERVER['HTTP_X_BLASTY_SIGNATURE'] ?? '')) {
http_response_code(401);
exit;
}
$event = json_decode($body, true); // dedupe on $event['id']