API referencev1

https://wacrm.junkiescoder.com/api/v1

Jump to section

Public REST API

Overview

Everything your integration can do lives under https://wacrm.junkiescoder.com/api/v1. Authenticate with an API key, send and receive JSON, and subscribe to webhooks instead of polling.

Every response uses the same envelope, so one parser handles the whole API. Success carries data; failure carries error.code and error.message.

Success
{
  "data": { "id": "…" }
}
List
{
  "data": [],
  "meta": { "next_cursor": null }
}
Failure
{
  "error": {
    "code": "bad_request",
    "message": "…"
  }
}

Building with an AI coding agent?

Copy a self-contained brief covering every endpoint, scope, error code, the rate limit, the pagination loop, and webhook signature verification. Paste it into ChatGPT, Claude, Cursor, or Copilot and it has everything it needs to write working integration code without guessing or asking follow-up questions.

Quick start

Three steps to a first successful call. Start with GET /api/v1/me — it needs no scope, so a green response proves your key and the whole auth path work before you touch real data.

  1. 1

    Create an API key

    In the dashboard, go to Settings → API keys and grant only the scopes you need. The full key is shown once, at creation — store it in a secret manager straight away.

  2. 2

    Verify it works

    Replace wacrm_live_YOUR_API_KEY with your key and run the snippet below.

  3. 3

    Build the real thing

    The response lists the scopes your key carries. Every endpoint below states the one it needs.

Verify your key — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/me' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'

Authentication

Send your key as a bearer token on every request. Keys are bound to a single account and are prefixed wacrm_live_, so they are easy to spot in a config file.

Scopes are fixed when a key is created. To add a capability, mint a new key rather than editing an existing one. Never ship a key in browser or mobile code — call the API from a server you control.

Request headers
Authorization: Bearer wacrm_live_YOUR_API_KEY
Content-Type: application/json

Scopes

messages:send1 endpoint

Send WhatsApp messages

messages:read1 endpoint

Read messages and their delivery status

contacts:read2 endpoints

List and read contacts

contacts:write2 endpoints

Create and update contacts

conversations:read2 endpoints

List and read conversations

broadcasts:send5 endpoints

Launch broadcast campaigns

webhooks:manage5 endpoints

Register and manage outbound event webhooks

Errors

Branch on the HTTP status first, then on error.code. The code is stable and safe to match in code; the message is for humans and may be reworded.

Retry internal and rate_limited with backoff. Do not blind-retry a 4xx — the request itself is wrong and will fail identically.

unauthorizedHTTP 401

Missing, malformed, unknown, revoked, or expired key. Deliberately the same response for all five so a probe cannot learn whether a key ever existed.

forbiddenHTTP 403

The key is valid but lacks the scope this endpoint requires. The message names the missing scope; mint a new key to add one (scopes are fixed at creation).

rate_limitedHTTP 429

More than 120 requests in 60s on one key. Honour Retry-After and retry.

bad_requestHTTP 400

Malformed input — a missing required field, a wrong type, or an unparseable id in a query filter. Retrying without changing the request will fail identically.

not_foundHTTP 404

No such resource in this key's account. Another account's resource returns 404, never 403, so ids cannot be probed across accounts.

internalHTTP 500

Something broke on our side. Safe to retry with backoff.

Rate limits

120 requests per 60 seconds, counted per API key. Going over returns 429 with code rate_limited.

Honour Retry-After and back off exponentially with jitter rather than retrying in a tight loop.

Retry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
Walk every page
# First page
curl 'https://wacrm.junkiescoder.com/api/v1/contacts?limit=100' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'

# Then repeat with the cursor from meta.next_cursor,
# stopping when it is null.
curl 'https://wacrm.junkiescoder.com/api/v1/contacts?limit=100&cursor=CURSOR_FROM_LAST_RESPONSE' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'

Endpoints

Identity

Verify a key and discover what it can do. Start here when wiring up a new integration — a green response proves the whole auth path works before you touch a real resource.

GET/api/v1/me

Whoami

Returns the account the key is bound to and the scopes it carries. The only endpoint that requires no scope, so it works with even an empty-scoped key.

Scopenone — any valid key

Responses

  • 200The key is live.

Worth knowing

  • Use this as a health check. It exercises bearer parsing, key hash lookup, liveness, the rate limiter, and the response envelope in one call.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/me' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": {
    "account": {
      "id": "9f1c2d3e-4b5a-6789-0abc-def123456789",
      "name": "Acme Inc"
    },
    "key": {
      "id": "1a2b3c4d-5e6f-7089-abcd-ef0123456789",
      "scopes": [
        "messages:send",
        "contacts:read"
      ]
    }
  }
}

Endpoints

Messages

Send WhatsApp messages and read a conversation history. Sending takes a phone number rather than an internal id, so an external automation never has to look up a conversation first.

POST/api/v1/messages

Send a message

Sends to a phone number, resolving-or-creating the contact and conversation on the way. Outside a 24-hour customer service window WhatsApp only permits `type: "template"`.

Scopemessages:send

Request body

tostringrequired

Recipient in E.164 format, e.g. +14155550123.

typestringoptional

One of text, template, interactive, contacts, location, location_request, sticker, image, video, document, audio. Defaults to text.

textstringoptional

Body for a text message, or the caption on a media message. Required when type is text. Captions are capped at 1024 characters.

media_urlstringoptional

Publicly reachable URL. Required for image, video, document, audio, and sticker.

filenamestringoptional

Filename shown to the recipient for a document.

templateobjectoptional

Required when type is template.

template.namestringrequired

Approved template name.

template.languagestringoptional

Language code, e.g. en_US.

template.paramsstring[] | objectoptional

An ARRAY fills the body variables {{1}}, {{2}}, … in order, and can do nothing else — a template with a media header, a header variable, or a URL button with {{1}} cannot be sent with the array form. Use the OBJECT form for those; its keys are listed below. Everything belongs inside template.params: unrecognised top-level keys in the request body are ignored silently.

template.params.bodystring[]optional

Values for the body variables {{1}}, {{2}}, … in order.

template.params.namedBodyobjectoptional

Body values for a NAMED-format template, keyed by parameter name instead of position. Mutually exclusive with body — a positional array sent for a named template would deliver the right values under the wrong labels.

template.params.headerTextstringoptional

Value for a TEXT header that contains {{1}}. Static text headers need nothing here.

template.params.headerMediaUrlstringoptional

Publicly reachable https:// link for an IMAGE, VIDEO or DOCUMENT header. Required on every send unless the template carries a default file. This is where a per-order invoice or ticket PDF goes.

template.params.headerMediaIdstringoptional

Alternative to headerMediaUrl: an id from a prior Meta media upload. Takes precedence when both are given.

template.params.headerDocumentFilenamestringoptional

The name the recipient sees on a DOCUMENT header, e.g. "ORD-5074.pdf". Usually unnecessary: when omitted it is derived from the last path segment of headerMediaUrl, so a link ending in /ORD-5074.pdf already arrives correctly named. Supply it when the URL is opaque (a hash or an id), or when sending by headerMediaId, which carries no filename to derive from. Include the extension — without one the file will not open by double-click. Ignored for image and video headers, which Meta does not accept a filename on.

template.params.headerLocationobjectoptional

For a LOCATION header: latitude, longitude, name and address. All four are required — Meta rejects a partial object.

template.params.buttonParamsobjectoptional

Values for URL buttons whose link contains {{1}}, and for COPY_CODE buttons. Keyed by the button’s index in the template, as a string: { "0": "ORD-123" }.

template.params.offerExpiresAtMsnumberoptional

Required for a limited-time offer template. UNIX timestamp in MILLISECONDS, not seconds. A past value is refused rather than sent as an already-dead offer.

template.params.cardsobject[]optional

Per-card values for a carousel, in card order. Each card takes headerMediaUrl or headerMediaId, body, and buttonParams. Only needed for cards whose text or URL button has variables.

reply_to_message_idstringoptional

Quote an earlier message. Must belong to the same conversation.

namestringoptional

Names the contact if this call creates it. Ignored for an existing contact.

Responses

  • 201Accepted by Meta and stored.

Errors specific to this endpoint

  • whatsapp_not_configured400This account has not connected a WhatsApp number yet.
  • recipient_opted_out409A marketing template was addressed to someone who opted out. Transactional templates still go through.
  • template_malformed500The locally stored template row is unusable — run Sync from Meta in Settings.
  • meta_error502The request reached Meta and Meta rejected it. The message text carries Meta’s reason.
  • db_error500Sent to Meta but not saved locally. The recipient got it; our copy is missing.

Worth knowing

  • The payload is validated BEFORE the contact and conversation are resolved, so a rejected request never leaves an orphan contact behind.
  • A 502 meta_error means the send genuinely failed at Meta. Do not blind-retry it — read the message first, because most causes (unapproved template, closed window) repeat.
  • Sending a template with a media header: put the file inside template.params, NOT at the top level of the body. The top-level media_url field is only for a plain media message (type: image/video/document) — it is ignored for type: template. A per-order invoice looks like this: { "to": "+14155550123", "type": "template", "template": { "name": "order_invoice_pdf", "language": "en_US", "params": { "headerMediaUrl": "https://example.com/ORD-5074.pdf", "body": ["Ravi", "ORD-5074"] } } }
  • template.params as an ARRAY only fills body variables. A template with a media header, a header variable, or a URL button containing {{1}} must use the object form — the array form has nowhere to put those values.
  • Set language to the template’s APPROVED language code. A template approved as "en" does not send as "en_US", and Meta’s rejection does not name the mismatch.
  • Unsure what a template needs? The single-campaign endpoint (/api/v1/campaigns/{id}) returns a template block giving the body variable count, whether a media header is required, and which buttons take values — build your form from that instead of guessing.
Request — cURL
curl -X POST 'https://wacrm.junkiescoder.com/api/v1/messages' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "to": "+14155550123",
  "type": "text",
  "text": "Your order #A123 has shipped."
}'
Response — 201
{
  "data": {
    "message_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "whatsapp_message_id": "wamid.HBgLMTQxNTU1NTAxMjMVAgARGB...",
    "conversation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "contact_id": "b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d",
    "contact_created": false
  }
}
GET/api/v1/conversations/{id}/messages

List messages in a conversation

Newest first, keyset-paginated. Ownership of the conversation is checked before any message is returned.

Scopemessages:readpaginated

Path parameters

iduuidrequired

Conversation id.

Query parameters

limitintegeroptional

1–100, default 50.

cursorstringoptional

Opaque cursor from a previous meta.next_cursor.

Responses

  • 200A page of messages.
  • 404Unknown conversation, or one belonging to another account.

Worth knowing

  • direction is derived: sender_type "customer" is inbound, anything else is outbound.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/conversations/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": [
    {
      "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
      "conversation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "direction": "inbound",
      "sender_type": "customer",
      "content_type": "text",
      "content_text": "Where is my order?",
      "media_url": null,
      "template_name": null,
      "whatsapp_message_id": "wamid.HBgLMTQxNTU1NTAxMjMVAgARGB...",
      "status": "read",
      "reply_to_message_id": null,
      "interactive_reply_id": null,
      "created_at": "2026-09-18T09:12:04.512Z"
    }
  ],
  "meta": {
    "next_cursor": null
  }
}

Endpoints

Contacts

Read, create, and update contacts. Creation is find-or-create by phone number, using the same de-duplication as the inbound webhook — so calling it twice cannot produce two rows for one person.

GET/api/v1/contacts

List contacts

Newest first, keyset-paginated, with optional search and tag filters.

Scopecontacts:readpaginated

Query parameters

limitintegeroptional

1–100, default 50.

cursorstringoptional

Opaque cursor from a previous meta.next_cursor.

searchstringoptional

Case-insensitive substring match on name or phone. Sanitized server-side.

taguuidoptional

Tag id — not a tag name. Returns contacts carrying that tag, each still with its full tag set.

Responses

  • 200A page of contacts.

Errors specific to this endpoint

  • bad_request400`tag` was not a valid UUID — a tag NAME is the usual mistake.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/contacts?limit=25' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": [
    {
      "id": "b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d",
      "phone": "+14155550123",
      "name": "Jane Doe",
      "email": "jane@example.com",
      "company": "Acme Inc",
      "avatar_url": null,
      "tags": [
        {
          "id": "2c4e6a80-1b3d-5f70-8a9c-bd0e1f2a3b4c",
          "name": "VIP",
          "color": "#22c55e"
        }
      ],
      "created_at": "2026-09-01T10:00:00.000Z",
      "updated_at": "2026-09-18T09:12:04.512Z"
    }
  ],
  "meta": {
    "next_cursor": "MjAyNi0wOS0wMVQxMDowMDowMC4wMDBafGIxYTdj..."
  }
}
POST/api/v1/contacts

Create a contact

Find-or-create by phone. The body is the contact either way; the STATUS tells you which happened — 201 created, 200 already existed.

Scopecontacts:write

Request body

phonestringrequired

E.164 format, e.g. +14155550123.

namestringoptional

Defaults to the phone number when omitted.

emailstringoptional

Email address.

companystringoptional

Company name.

tagsstring[]optional

Tag NAMES (not ids). Matched case-insensitively; unknown names are created.

Responses

  • 201A new contact was created.
  • 200An existing contact matched this phone number and was returned unchanged.

Errors specific to this endpoint

  • bad_request400`phone` is missing, or not valid E.164.

Worth knowing

  • Branch on the status code, not the body — there is no `created` field.
  • An existing contact keeps the source it was first seen with; only a contact this call creates is labelled as API-originated.
Request — cURL
curl -X POST 'https://wacrm.junkiescoder.com/api/v1/contacts' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "phone": "+14155550123",
  "name": "Jane Doe",
  "email": "jane@example.com",
  "tags": [
    "VIP"
  ]
}'
GET/api/v1/contacts/{id}

Read a contact

One contact with its tags.

Scopecontacts:read

Path parameters

iduuidrequired

Contact id.

Responses

  • 200The contact.
  • 404Unknown id, or another account’s contact.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/contacts/b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
PATCH/api/v1/contacts/{id}

Update a contact

Only the fields present in the body change. Send null to clear one; omit it to leave it alone.

Scopecontacts:write

Path parameters

iduuidrequired

Contact id.

Request body

namestring | nulloptional

Any other type is a 400 rather than a silent no-op.

emailstring | nulloptional

Email address.

companystring | nulloptional

Company name.

tagsstring[]optional

REPLACES the tag set with exactly these names. Pass [] to clear all tags.

Responses

  • 200The updated contact.
  • 404Unknown id, or another account’s contact.

Worth knowing

  • `tags` is a replace, not a merge — read the contact first if you mean to add one.
Request — cURL
curl -X PATCH 'https://wacrm.junkiescoder.com/api/v1/contacts/b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "company": "Acme Inc",
  "tags": [
    "VIP",
    "Renewal"
  ]
}'

Endpoints

Conversations

Browse threads. Each conversation embeds its contact and that contact’s tags, so a list view needs no follow-up calls.

GET/api/v1/conversations

List conversations

Newest first, keyset-paginated.

Scopeconversations:readpaginated

Query parameters

limitintegeroptional

1–100, default 50.

cursorstringoptional

Opaque cursor from a previous meta.next_cursor.

statusstringoptional

open, pending, or closed. An unrecognised value matches nothing and returns an empty page.

contact_iduuidoptional

Only this contact’s conversations.

Responses

  • 200A page of conversations.

Errors specific to this endpoint

  • bad_request400`contact_id` was not a valid UUID.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/conversations?status=open&limit=25' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "contact_id": "b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d",
      "status": "open",
      "assigned_agent_id": null,
      "last_message_text": "Where is my order?",
      "last_message_at": "2026-09-18T09:12:04.512Z",
      "unread_count": 1,
      "created_at": "2026-09-01T10:00:00.000Z",
      "updated_at": "2026-09-18T09:12:04.512Z",
      "contact": {
        "id": "b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d",
        "phone": "+14155550123",
        "name": "Jane Doe",
        "email": "jane@example.com",
        "company": "Acme Inc",
        "tags": []
      }
    }
  ],
  "meta": {
    "next_cursor": null
  }
}
GET/api/v1/conversations/{id}

Read a conversation

One conversation with its embedded contact.

Scopeconversations:read

Path parameters

iduuidrequired

Conversation id.

Responses

  • 200The conversation.
  • 404Unknown id, or another account’s conversation.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/conversations/3fa85f64-5717-4562-b3fc-2c963f66afa6' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'

Endpoints

Broadcasts

Send one approved template to many recipients in a single call, then poll for progress. Use this for an ad-hoc send; use a Campaign when the same send repeats.

POST/api/v1/broadcasts

Launch a broadcast

Persists the broadcast and its recipients, then fans out to Meta after the response is sent. Returns 202 — the send is under way, not finished.

Scopebroadcasts:send

Request body

template_namestringrequired

An approved template.

template_languagestringoptional

Language code. Defaults to en_US.

namestringoptional

Label shown in the dashboard.

recipientsobject[]required

1–1000 entries.

recipients[].tostringrequired

E.164 phone number.

recipients[].paramsstring[]optional

Fills the template body variables in order.

Responses

  • 202Accepted; the fan-out is running.

Errors specific to this endpoint

  • bad_request400Missing template_name, empty recipients, more than 1000 recipients, or no recipient with a valid E.164 number.
  • whatsapp_not_configured400No WhatsApp number connected on this account.
  • all_recipients_opted_out400Every recipient has opted out of marketing from this account.
  • template_malformed500The locally stored template row is unusable — re-sync from Meta.

Worth knowing

  • rejected counts recipients dropped for an unusable phone number. suppressed counts marketing opt-outs — they get no recipient row, so they appear only in this response and never in the persisted counts.
  • Capped at 1000 per request. A near-cap audience can exceed the 60-second fan-out budget, so split very large sends across calls.
Request — cURL
curl -X POST 'https://wacrm.junkiescoder.com/api/v1/broadcasts' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "name": "September promo",
  "template_name": "promo_september",
  "template_language": "en_US",
  "recipients": [
    {
      "to": "+14155550123",
      "params": [
        "Jane"
      ]
    },
    {
      "to": "+14155550124",
      "params": [
        "Ravi"
      ]
    }
  ]
}'
Response — 202
{
  "data": {
    "broadcast_id": "8d5f1e2a-3b4c-5d6e-7f80-91a2b3c4d5e6",
    "status": "sending",
    "total_recipients": 2,
    "accepted": 2,
    "rejected": 0,
    "suppressed": 0
  }
}
GET/api/v1/broadcasts/{id}

Get broadcast status

Poll after launching. status moves sending → sent, while the delivered and read counts keep climbing as Meta webhooks arrive.

Scopebroadcasts:send

Path parameters

iduuidrequired

Broadcast id from the launch response.

Responses

  • 200Current counts.
  • 404Unknown id, or another account’s broadcast.

Worth knowing

  • Counts settle as delivery webhooks arrive, so a freshly finished broadcast can still show delivered_count climbing for a while.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/broadcasts/8d5f1e2a-3b4c-5d6e-7f80-91a2b3c4d5e6' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": {
    "id": "8d5f1e2a-3b4c-5d6e-7f80-91a2b3c4d5e6",
    "name": "September promo",
    "template_name": "promo_september",
    "template_language": "en_US",
    "status": "sent",
    "total_recipients": 2,
    "sent_count": 2,
    "delivered_count": 2,
    "read_count": 1,
    "replied_count": 0,
    "failed_count": 0,
    "created_at": "2026-09-18T09:00:00.000Z",
    "updated_at": "2026-09-18T09:01:12.004Z"
  }
}

Endpoints

Campaigns

A campaign is a saved, recipient-less send definition you trigger repeatedly — the right shape for a spreadsheet row or a backend event. Every trigger is idempotent, so a retry cannot double-send.

GET/api/v1/campaigns

List campaigns

The smallest shape that populates a picker. What a template NEEDS to send lives on the single-campaign endpoint instead.

Scopebroadcasts:sendpaginated

Query parameters

limitintegeroptional

1–100, default 50.

cursorstringoptional

Opaque cursor from a previous meta.next_cursor.

Responses

  • 200A page of campaigns.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/campaigns' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": [
    {
      "id": "c0ffee00-1111-2222-3333-444455556666",
      "name": "Order shipped",
      "template_name": "order_shipped",
      "template_language": "en_US",
      "status": "active",
      "created_at": "2026-09-10T08:00:00.000Z"
    }
  ],
  "meta": {
    "next_cursor": null
  }
}
GET/api/v1/campaigns/{id}

Read a campaign and its send plan

Adds a `template` block describing exactly what the template needs — how many body variables, whether a media header is required, which buttons take values. Build your form from this instead of hard-coding it.

Scopebroadcasts:send

Path parameters

iduuidrequired

Campaign id.

Responses

  • 200The campaign, its send plan, and a template_warning that is null when all is well.
  • 404Unknown id, or another account’s campaign.

Worth knowing

  • A campaign can outlive its template. When that happens `template` is null and `template_warning` explains why — reported plainly rather than as a 500, because the campaign itself is still real.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/campaigns/c0ffee00-1111-2222-3333-444455556666' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": {
    "id": "c0ffee00-1111-2222-3333-444455556666",
    "name": "Order shipped",
    "template_name": "order_shipped",
    "template_language": "en_US",
    "status": "active",
    "created_at": "2026-09-10T08:00:00.000Z",
    "template": {
      "category": "UTILITY",
      "body_variable_count": 2,
      "body_variable_names": [],
      "parameter_format": "POSITIONAL",
      "header": null,
      "needs_header_location": false,
      "url_buttons": [],
      "copy_code_buttons": [],
      "offer": null,
      "is_order_status": false,
      "is_authentication": false,
      "commerce": null,
      "cards": [],
      "needs_no_input": false
    },
    "template_warning": null
  }
}
POST/api/v1/campaigns/{id}/send

Trigger a campaign send

Requires a caller-computed idempotency_key. Replaying the same key sends nothing and returns the original broadcast id, so a retry over a flaky connection is always safe.

Scopebroadcasts:send

Path parameters

iduuidrequired

Campaign id.

Request body

idempotency_keystringrequired

Deterministic key for this logical send, 200 characters or fewer. Derive it from something stable, e.g. a spreadsheet row id plus the values sent.

recipientsobject[]required

Non-empty.

recipients[].tostringrequired

E.164 phone number.

recipients[].namestringoptional

Names the contact if this call creates it.

recipients[].paramsstring[]optional

Fills the template body variables in order.

recipients[].media_urlstringoptional

Header media for templates that require it.

recipients[].button_paramsobjectoptional

Button index (as a string key) to its value, e.g. {"0":"ORD-123"}.

param_labelsstring[]optional

Names each positional value in params so the report reads "Order id: 393392" instead of an unlabelled column. Describes the whole call, not one recipient.

sourceobjectoptional

Free-form provenance stored on the run, e.g. {"kind":"google_sheets","spreadsheet_id":"…"}. Its `kind` also suffixes the run name so repeat runs are tellable apart.

Responses

  • 202Accepted. status is "sending" when delivered inline, or "scheduled" when handed to the background sweep.
  • 200This idempotency_key was already used. Nothing was sent; broadcast_id points at the original run.
  • 404Unknown id, or another account’s campaign.

Errors specific to this endpoint

  • bad_request400idempotency_key missing or over 200 characters, or recipients empty.
  • campaign_paused403The campaign is paused. Resume it in the dashboard.
  • subscription_inactive403This workspace needs an active subscription to send.

Worth knowing

  • Up to 25 recipients are delivered inline and come back status "sending". More than that is queued and comes back status "scheduled", drained by a sweep that runs every five minutes.
  • Branch on status 200 vs 202, or on the `duplicate` field, to tell a replay from a fresh send.
  • The key is only spent once a run is actually created. If creation fails the claim is released, so a corrected retry with the same key genuinely tries again.
Request — cURL
curl -X POST 'https://wacrm.junkiescoder.com/api/v1/campaigns/c0ffee00-1111-2222-3333-444455556666/send' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "idempotency_key": "sheet1-row42-ORD123",
  "recipients": [
    {
      "to": "+14155550123",
      "name": "Jane",
      "params": [
        "ORD-123",
        "2 days"
      ]
    }
  ],
  "param_labels": [
    "Order id",
    "Delivery window"
  ],
  "source": {
    "kind": "google_sheets",
    "spreadsheet_id": "1AbC…",
    "sheet": "Sheet1"
  }
}'
Response — 202
{
  "data": {
    "broadcast_id": "8d5f1e2a-3b4c-5d6e-7f80-91a2b3c4d5e6",
    "status": "sending",
    "total_recipients": 1,
    "accepted": 1,
    "rejected": 0,
    "suppressed": 0
  }
}

Endpoints

Webhooks

Register HTTPS endpoints to receive events instead of polling. Each delivery is signed, so you can prove it came from us.

GET/api/v1/webhooks

List webhook endpoints

Returns the whole roster — it is settings-class and small, so meta.next_cursor is always null here.

Scopewebhooks:manage

Responses

  • 200Every endpoint on the account.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/webhooks' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": [
    {
      "id": "aa11bb22-cc33-dd44-ee55-ff6677889900",
      "url": "https://example.com/hooks/wacrm",
      "events": [
        "message.received",
        "message.status_updated"
      ],
      "is_active": true,
      "last_delivery_at": "2026-09-18T09:12:05.000Z",
      "failure_count": 0,
      "created_at": "2026-09-01T10:00:00.000Z"
    }
  ],
  "meta": {
    "next_cursor": null
  }
}
POST/api/v1/webhooks

Register a webhook endpoint

Returns the signing secret in plaintext exactly once. Store it now — we keep only an encrypted copy and can never show it again.

Scopewebhooks:manage

Request body

urlstringrequired

Absolute https:// URL. Plain http is rejected, since it would leak signed payloads.

eventsstring[]required

Non-empty list drawn from: message.received, message.status_updated, conversation.created, message.sent, message.edited, message.deleted, whatsapp.disconnected.

Responses

  • 201Created. `secret` appears in this response only.

Errors specific to this endpoint

  • bad_request400url is not a valid https URL, or events is empty or has an unknown name.
Request — cURL
curl -X POST 'https://wacrm.junkiescoder.com/api/v1/webhooks' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "url": "https://example.com/hooks/wacrm",
  "events": [
    "message.received",
    "message.status_updated"
  ]
}'
Response — 201
{
  "data": {
    "id": "aa11bb22-cc33-dd44-ee55-ff6677889900",
    "url": "https://example.com/hooks/wacrm",
    "events": [
      "message.received",
      "message.status_updated"
    ],
    "is_active": true,
    "last_delivery_at": null,
    "failure_count": 0,
    "created_at": "2026-09-18T09:00:00.000Z",
    "secret": "whsec_XQf3kL9…"
  }
}
GET/api/v1/webhooks/{id}

Read a webhook endpoint

The secret is never included here.

Scopewebhooks:manage

Path parameters

iduuidrequired

Endpoint id.

Responses

  • 200The endpoint.
  • 404Unknown id, or another account’s endpoint.
Request — cURL
curl 'https://wacrm.junkiescoder.com/api/v1/webhooks/aa11bb22-cc33-dd44-ee55-ff6677889900' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
PATCH/api/v1/webhooks/{id}

Update a webhook endpoint

Change the url, the event subscription, or the active flag. Re-enabling also clears the failure streak.

Scopewebhooks:manage

Path parameters

iduuidrequired

Endpoint id.

Request body

urlstringoptional

New https:// URL.

eventsstring[]optional

Replaces the subscription. Must be non-empty.

is_activebooleanoptional

Pause or resume. Setting it true also resets failure_count to 0.

Responses

  • 200The updated endpoint.
  • 404Unknown id, or another account’s endpoint.

Errors specific to this endpoint

  • bad_request400No updatable field was supplied, or one of them was invalid.
Request — cURL
curl -X PATCH 'https://wacrm.junkiescoder.com/api/v1/webhooks/aa11bb22-cc33-dd44-ee55-ff6677889900' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
  "is_active": true
}'
DELETE/api/v1/webhooks/{id}

Delete a webhook endpoint

Permanent. Deliveries stop immediately.

Scopewebhooks:manage

Path parameters

iduuidrequired

Endpoint id.

Responses

  • 200Deleted.
  • 404Unknown id, or another account’s endpoint.
Request — cURL
curl -X DELETE 'https://wacrm.junkiescoder.com/api/v1/webhooks/aa11bb22-cc33-dd44-ee55-ff6677889900' \
  -H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'
Response — 200
{
  "data": {
    "id": "aa11bb22-cc33-dd44-ee55-ff6677889900",
    "deleted": true
  }
}

Events

Webhooks

Register an HTTPS endpoint to receive events instead of polling. Manage endpoints through POST /api/v1/webhooks, which returns the signing secret exactly once.

Events

message.received

An inbound message was received from a contact

message.status_updated

A message you sent changed delivery status (sent/delivered/read/failed)

conversation.created

A new conversation was opened

message.sent

An outbound message was sent outside this CRM — currently, one typed in the WhatsApp Business App on a phone (check sender_type)

message.edited

A stored message was edited by its sender

message.deleted

A stored message was deleted for everyone by its sender

whatsapp.disconnected

Meta disconnected your WhatsApp number — sending will fail until it is reconnected

Verifying a delivery

  1. 1. Read t and v1 from the X-Wacrm-Signature header.
  2. 2. Reject the request if t is more than 300 seconds from now. That is the replay guard.
  3. 3. Compute HMAC-SHA256 over ${timestamp}.${rawRequestBody}, keyed with the endpoint’s signing secret, and hex-encode it.
  4. 4. Compare your digest to v1 with a constant-time comparison.
  • Verify against the RAW request body, before any JSON parse — a re-serialized copy will not match the HMAC.
  • Compare in constant time, and reject a timestamp outside your tolerance window to block replays.
  • Delivery is a single attempt with no retry. Respond 2xx quickly and do your real work asynchronously.
  • Each consecutive failure increments failure_count; at 15 the endpoint is auto-disabled so a dead sink stops being hit. Any success resets the counter.
  • Redirects are not followed, and endpoints resolving to private or loopback addresses are refused. Both count as failures.
  • Deduplicate on the payload `id` — it is unique per delivery.
Delivery headers
X-Wacrm-Event: message.received
X-Wacrm-Webhook-Id: aa11bb22-cc33-dd44-ee55-ff6677889900
X-Wacrm-Signature: t=<unix_seconds>,v1=<hex HMAC-SHA256>
Payload
{
  "id": "d3b07384-d9a0-4f9b-8b2e-1c5a6f7e8d90",
  "event": "message.received",
  "occurred_at": "2026-09-18T09:12:04.512Z",
  "account_id": "9f1c2d3e-4b5a-6789-0abc-def123456789",
  "data": {
    "conversation_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "contact_id": "b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d"
  }
}
API reference — Replai