REST API v1

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.

Base URL
Authentication header

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!"}'
Sending endpoints (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

A key can be pinned to one of your numbers when you create it. A pinned key always sends from that number, so 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

GET /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

GET /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.

GET /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

GET /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.

GET /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).

POST /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
}
POST /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

GET /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.

POST /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.

POST /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.

POST /api/v1/campaigns/{id}/resume

Resume a campaign

Carry on with a paused campaign at the usual pace. Returns 409 unless it is paused.

GET /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 }
  }
}
POST /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

GET /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, "…": "…" }
}
GET /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

GET /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).

POST /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" }
  ]
}
DELETE /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.

NormalReverse
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

Your user’s WhatsApp

YB Your Business +60 11-111 1111

No messages yet

API traffic

Requests between your server and Blasty show up here.

Try it on your own phone

Create an account, link a number, and send yourself a real OTP in either mode from the OTP page.

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. 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 the id: 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. 2

    The user gets the code on WhatsApp

    From a QR number it reads like this (in Malay with "language": "ms"; app_name adds a [YourApp] prefix). From a Meta number it goes out as your approved blasty_otp authentication 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. 3

    Check the code the user typed

    Every call uses one attempt, right or wrong. The right code answers 200 and the OTP is verified; anything else answers 422 with a reason. 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. 4

    Optionally, let Blasty call you

    With a callback_url your server also gets otp.verified, or otp.expired if 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. 1

    Start it from your server

    No phone needed. The answer adds the code, the ready-made text, a one-tap link and the whatsapp_number to message. These four come back only in this response. 1 credit, taken now (it also pays for the ✅ reply). Already know the number? Pass phone and 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. 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 text and whatsapp_number for anyone who prefers to type it.

    HTML

    <a href="https://wa.me/60111111111?text=My%20verification%20code%20is%20K7Q2MX">Verify with WhatsApp</a>
  3. 3

    The user taps Send

    Blasty finds the code anywhere in the message, in any letter case, marks the OTP verified and records the sender as verified_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. 4

    Find out it’s done

    Poll GET /otp/{id} from your server every 2–3 seconds while your page waits, until status is no longer pending (give up at expires_at), or wait for the otp.verified callback. Then sign the user in as verified_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

POST /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).

FieldTypeRequiredDescription
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.
GET /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.

POST /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.

ReasonMeaning
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

FieldTypeDescription
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

Pending Verified the code matched · Expired time ran out · Failed too many wrong codes (normal)

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.

StatusWhen
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 (429 before that). Pick a ttl that fits: 5 minutes by default, up to 30.
  • Use reference to 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 phone may not match on a QR number. Meta numbers always report it; or leave phone out and check verified_phone yourself.

Contacts

GET /api/v1/contacts

List contacts

Paginated, searchable contact list. Filter with ?search= (name or phone) or ?external_id=, and set ?per_page= (default 25).

POST /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" }
}
GET /api/v1/contacts/{id}

Get contact

Fetch a single contact.

PUT /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.

DELETE /api/v1/contacts/{id}

Delete contact

Permanently delete a contact.

Templates

GET /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).

POST /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"]
}
GET /api/v1/templates/{id}

Get template

Fetch a single template.

PUT /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.

DELETE /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']