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.
{
"data": { "id": "…" }
}{
"data": [],
"meta": { "next_cursor": null }
}{
"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
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
Verify it works
Replace
wacrm_live_YOUR_API_KEYwith your key and run the snippet below. - 3
Build the real thing
The response lists the scopes your key carries. Every endpoint below states the one it needs.
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.
Authorization: Bearer wacrm_live_YOUR_API_KEY
Content-Type: application/jsonScopes
messages:send1 endpointSend WhatsApp messages
messages:read1 endpointRead messages and their delivery status
contacts:read2 endpointsList and read contacts
contacts:write2 endpointsCreate and update contacts
conversations:read2 endpointsList and read conversations
broadcasts:send5 endpointsLaunch broadcast campaigns
webhooks:manage5 endpointsRegister 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 401Missing, malformed, unknown, revoked, or expired key. Deliberately the same response for all five so a probe cannot learn whether a key ever existed.
forbiddenHTTP 403The 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 429More than 120 requests in 60s on one key. Honour Retry-After and retry.
bad_requestHTTP 400Malformed 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 404No such resource in this key's account. Another account's resource returns 404, never 403, so ids cannot be probed across accounts.
internalHTTP 500Something 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-ResetPagination
List endpoints are keyset-paginated, newest first. Pass limit (1–100, default 50) and follow meta.next_cursor until it comes back null.
Cursors are opaque — pass them back verbatim, never parse or build one. Keyset paging stays stable while rows are being inserted, so a full scan will not skip or repeat records the way an offset would.
# 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.
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.
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.
curl 'https://wacrm.junkiescoder.com/api/v1/me' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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.
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"`.
messages:sendRequest body
tostringrequiredRecipient in E.164 format, e.g. +14155550123.
typestringoptionalOne of text, template, interactive, contacts, location, location_request, sticker, image, video, document, audio. Defaults to text.
textstringoptionalBody for a text message, or the caption on a media message. Required when type is text. Captions are capped at 1024 characters.
media_urlstringoptionalPublicly reachable URL. Required for image, video, document, audio, and sticker.
filenamestringoptionalFilename shown to the recipient for a document.
templateobjectoptionalRequired when type is template.
template.namestringrequiredApproved template name.
template.languagestringoptionalLanguage code, e.g. en_US.
template.paramsstring[] | objectoptionalAn 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[]optionalValues for the body variables {{1}}, {{2}}, … in order.
template.params.namedBodyobjectoptionalBody 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.headerTextstringoptionalValue for a TEXT header that contains {{1}}. Static text headers need nothing here.
template.params.headerMediaUrlstringoptionalPublicly 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.headerMediaIdstringoptionalAlternative to headerMediaUrl: an id from a prior Meta media upload. Takes precedence when both are given.
template.params.headerDocumentFilenamestringoptionalThe 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.headerLocationobjectoptionalFor a LOCATION header: latitude, longitude, name and address. All four are required — Meta rejects a partial object.
template.params.buttonParamsobjectoptionalValues 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.offerExpiresAtMsnumberoptionalRequired 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[]optionalPer-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_idstringoptionalQuote an earlier message. Must belong to the same conversation.
namestringoptionalNames 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.
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."
}'{
"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
}
}List messages in a conversation
Newest first, keyset-paginated. Ownership of the conversation is checked before any message is returned.
messages:readpaginatedPath parameters
iduuidrequiredConversation id.
Query parameters
limitintegeroptional1–100, default 50.
cursorstringoptionalOpaque 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.
curl 'https://wacrm.junkiescoder.com/api/v1/conversations/3fa85f64-5717-4562-b3fc-2c963f66afa6/messages' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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.
List contacts
Newest first, keyset-paginated, with optional search and tag filters.
contacts:readpaginatedQuery parameters
limitintegeroptional1–100, default 50.
cursorstringoptionalOpaque cursor from a previous meta.next_cursor.
searchstringoptionalCase-insensitive substring match on name or phone. Sanitized server-side.
taguuidoptionalTag 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.
curl 'https://wacrm.junkiescoder.com/api/v1/contacts?limit=25' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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..."
}
}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.
contacts:writeRequest body
phonestringrequiredE.164 format, e.g. +14155550123.
namestringoptionalDefaults to the phone number when omitted.
emailstringoptionalEmail address.
companystringoptionalCompany name.
tagsstring[]optionalTag 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.
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"
]
}'Read a contact
One contact with its tags.
contacts:readPath parameters
iduuidrequiredContact id.
Responses
200The contact.404Unknown id, or another account’s contact.
curl 'https://wacrm.junkiescoder.com/api/v1/contacts/b1a7c3d2-8e4f-4a5b-9c6d-7e8f9a0b1c2d' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'Update a contact
Only the fields present in the body change. Send null to clear one; omit it to leave it alone.
contacts:writePath parameters
iduuidrequiredContact id.
Request body
namestring | nulloptionalAny other type is a 400 rather than a silent no-op.
emailstring | nulloptionalEmail address.
companystring | nulloptionalCompany name.
tagsstring[]optionalREPLACES 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.
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.
List conversations
Newest first, keyset-paginated.
conversations:readpaginatedQuery parameters
limitintegeroptional1–100, default 50.
cursorstringoptionalOpaque cursor from a previous meta.next_cursor.
statusstringoptionalopen, pending, or closed. An unrecognised value matches nothing and returns an empty page.
contact_iduuidoptionalOnly this contact’s conversations.
Responses
200A page of conversations.
Errors specific to this endpoint
bad_request400`contact_id` was not a valid UUID.
curl 'https://wacrm.junkiescoder.com/api/v1/conversations?status=open&limit=25' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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
}
}Read a conversation
One conversation with its embedded contact.
conversations:readPath parameters
iduuidrequiredConversation id.
Responses
200The conversation.404Unknown id, or another account’s conversation.
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.
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.
broadcasts:sendRequest body
template_namestringrequiredAn approved template.
template_languagestringoptionalLanguage code. Defaults to en_US.
namestringoptionalLabel shown in the dashboard.
recipientsobject[]required1–1000 entries.
recipients[].tostringrequiredE.164 phone number.
recipients[].paramsstring[]optionalFills 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.
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"
]
}
]
}'{
"data": {
"broadcast_id": "8d5f1e2a-3b4c-5d6e-7f80-91a2b3c4d5e6",
"status": "sending",
"total_recipients": 2,
"accepted": 2,
"rejected": 0,
"suppressed": 0
}
}Get broadcast status
Poll after launching. status moves sending → sent, while the delivered and read counts keep climbing as Meta webhooks arrive.
broadcasts:sendPath parameters
iduuidrequiredBroadcast 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.
curl 'https://wacrm.junkiescoder.com/api/v1/broadcasts/8d5f1e2a-3b4c-5d6e-7f80-91a2b3c4d5e6' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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.
List campaigns
The smallest shape that populates a picker. What a template NEEDS to send lives on the single-campaign endpoint instead.
broadcasts:sendpaginatedQuery parameters
limitintegeroptional1–100, default 50.
cursorstringoptionalOpaque cursor from a previous meta.next_cursor.
Responses
200A page of campaigns.
curl 'https://wacrm.junkiescoder.com/api/v1/campaigns' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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
}
}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.
broadcasts:sendPath parameters
iduuidrequiredCampaign 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.
curl 'https://wacrm.junkiescoder.com/api/v1/campaigns/c0ffee00-1111-2222-3333-444455556666' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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
}
}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.
broadcasts:sendPath parameters
iduuidrequiredCampaign id.
Request body
idempotency_keystringrequiredDeterministic 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[]requiredNon-empty.
recipients[].tostringrequiredE.164 phone number.
recipients[].namestringoptionalNames the contact if this call creates it.
recipients[].paramsstring[]optionalFills the template body variables in order.
recipients[].media_urlstringoptionalHeader media for templates that require it.
recipients[].button_paramsobjectoptionalButton index (as a string key) to its value, e.g. {"0":"ORD-123"}.
param_labelsstring[]optionalNames each positional value in params so the report reads "Order id: 393392" instead of an unlabelled column. Describes the whole call, not one recipient.
sourceobjectoptionalFree-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.
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"
}
}'{
"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.
List webhook endpoints
Returns the whole roster — it is settings-class and small, so meta.next_cursor is always null here.
webhooks:manageResponses
200Every endpoint on the account.
curl 'https://wacrm.junkiescoder.com/api/v1/webhooks' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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
}
}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.
webhooks:manageRequest body
urlstringrequiredAbsolute https:// URL. Plain http is rejected, since it would leak signed payloads.
eventsstring[]requiredNon-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.
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"
]
}'{
"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…"
}
}Read a webhook endpoint
The secret is never included here.
webhooks:managePath parameters
iduuidrequiredEndpoint id.
Responses
200The endpoint.404Unknown id, or another account’s endpoint.
curl 'https://wacrm.junkiescoder.com/api/v1/webhooks/aa11bb22-cc33-dd44-ee55-ff6677889900' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'Update a webhook endpoint
Change the url, the event subscription, or the active flag. Re-enabling also clears the failure streak.
webhooks:managePath parameters
iduuidrequiredEndpoint id.
Request body
urlstringoptionalNew https:// URL.
eventsstring[]optionalReplaces the subscription. Must be non-empty.
is_activebooleanoptionalPause 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.
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 a webhook endpoint
Permanent. Deliveries stop immediately.
webhooks:managePath parameters
iduuidrequiredEndpoint id.
Responses
200Deleted.404Unknown id, or another account’s endpoint.
curl -X DELETE 'https://wacrm.junkiescoder.com/api/v1/webhooks/aa11bb22-cc33-dd44-ee55-ff6677889900' \
-H 'Authorization: Bearer wacrm_live_YOUR_API_KEY'{
"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.receivedAn inbound message was received from a contact
message.status_updatedA message you sent changed delivery status (sent/delivered/read/failed)
conversation.createdA new conversation was opened
message.sentAn outbound message was sent outside this CRM — currently, one typed in the WhatsApp Business App on a phone (check sender_type)
message.editedA stored message was edited by its sender
message.deletedA stored message was deleted for everyone by its sender
whatsapp.disconnectedMeta disconnected your WhatsApp number — sending will fail until it is reconnected
Verifying a delivery
- 1. Read
tandv1from theX-Wacrm-Signatureheader. - 2. Reject the request if
tis more than 300 seconds from now. That is the replay guard. - 3. Compute HMAC-SHA256 over
${timestamp}.${rawRequestBody}, keyed with the endpoint’s signing secret, and hex-encode it. - 4. Compare your digest to
v1with 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.
X-Wacrm-Event: message.received
X-Wacrm-Webhook-Id: aa11bb22-cc33-dd44-ee55-ff6677889900
X-Wacrm-Signature: t=<unix_seconds>,v1=<hex HMAC-SHA256>{
"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"
}
}