BluetickedDeveloper docs

WhatsApp

Templates, the 24-hour service window, and what Meta requires of a business-initiated send.

WhatsApp: getting live

  1. 1
    Send it with a test keyNo number or approval needed yet: on a blu_test_ key, POST /api/v1/templates makes a template that is approved at once in the sandbox, and a send with it walks queued, sent, delivered and read without calling Meta or spending anything.Send from the console
  2. 2
    Connect a WhatsApp numberLink a WhatsApp Business number through Meta. Until one is connected, live sends are refused with channel_unavailable.Connect a number
  3. 3
    Get a template approvedBusiness-initiated messages must use a Meta-approved template. Approval usually lands within minutes, but plan for longer.Submit a template
  4. 4
    Handle the replyOnce a customer replies you may send free-form messages for 24 hours. Subscribe to message.received so you know the window is open.Set up webhooks

Essentials

Templates and the 24-hour window
Business-initiated WhatsApp messages must use a Meta-approved template. Once a customer replies, free-form messages are allowed for 24 hours from their last message.
Template rules
Choose the category for what the message does: utility only for updates about something the customer already has (an order, a booking, an account), with no promotional wording, or Meta rejects it or moves it to marketing; marketing for anything promotional; authentication for codes. Number variables {{1}}, {{2}} and on, in order with none skipped (the dashboard checks this before it submits; Meta adds its own placement rules). A template with a DOCUMENT header (a statement or an invoice) is made in the dashboard; the API creates templates without a header. A rejected template keeps Meta's reason on it: correct it in the dashboard and submit it again.
Forms are WhatsApp Flows
Blueticked Forms are built on Meta's WhatsApp Flows: native multi-screen forms inside the chat, with optional live data from your server.
One number, one WABA
Sends go out through the connected number's WhatsApp Business Account. Templates belong to a WABA; pass channel_id to pick the sending number explicitly.
Delivery events
Subscribe a webhook to message.sent / delivered / read / failed and message.received. Polling GET /messages/{id} is for development only. Authorize WhatsApp sends with messages:send or the least-privilege whatsapp:send scope.

WhatsApp API endpoints

POST/api/v1/messagesmessages:sendTry in console

Send a customer statement (template with PDF)

Send a WhatsApp template that has a DOCUMENT header, attaching a per-recipient PDF by URL.

Body parameters
channelstring
toobject
template_idstring
template_variablesobject
documentobject
  • The template must be approved with a DOCUMENT header; templates without one reject the document parameter with document_not_applicable.
  • document.url must be an https:// URL that WhatsApp can fetch without credentials at send time. It is fetched once, when the message is sent, and never afterwards. A short-lived pre-signed URL (S3, GCS, Supabase storage; a few minutes of TTL is ample) satisfies this and is the recommended pattern for anything confidential: payslips, statements, invoices. The file never has to be durably public. filename is what the recipient sees (max 240 characters).
  • Or send a PDF uploaded with POST /api/v1/documents by its id: document: { id }, with an optional filename to show instead of the uploaded one. It is sent only to the contact it was uploaded for: another contact's document, or one sent to a contact to.create would create, is refused with 400 validation_failed, details.reason: "document_not_for_contact", and an id this workspace does not hold answers 404 not_found, document_not_found. Pass id or url, never both.
  • Omitting document on a DOCUMENT-header template returns document_required.
  • Authorize WhatsApp sends with either messages:send or the least-privilege whatsapp:send scope.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "channel": "whatsapp",
  "to": {
    "phone": "+27800000001"
  },
  "template_id": "1f2f3f4f-0000-4000-9000-abcdef123456",
  "template_variables": {
    "1": "Sam",
    "2": "May 2026"
  },
  "document": {
    "url": "https://files.example.com/statements/acct-1001.pdf",
    "filename": "Statement May 2026.pdf"
  }
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/messagesmessages:sendTry in console

Send a template with a personalised link button

Send a WhatsApp template whose URL button ends in {{1}}, supplying the per-send link ending.

Body parameters
channelstring
toobject
template_idstring
template_variablesobject
template_buttonsobject[]
  • A dynamic URL button is one whose stored URL ends in {{1}}; url_suffix is the value that replaces it for this send.
  • index is the button's position in the template's buttons array (0-based).
  • Templates with a dynamic URL button REQUIRE a matching template_buttons entry. The send is rejected with button_params_mismatch otherwise; a suffix aimed at a static button is rejected the same way.
  • WhatsApp template sends only; template_buttons on other channels or without template_id fails validation.
  • Authorize WhatsApp sends with either messages:send or the least-privilege whatsapp:send scope.
curl -X POST "https://app.blueticked.com/api/v1/messages" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "channel": "whatsapp",
  "to": {
    "phone": "+27800000001"
  },
  "template_id": "1f2f3f4f-0000-4000-9000-abcdef123456",
  "template_variables": {
    "1": "Sam"
  },
  "template_buttons": [
    {
      "type": "url",
      "index": 1,
      "url_suffix": "orders/RIPL-4421"
    }
  ]
}'
Response
{
  "message_id": "763c4dc3-d46b-4c10-8bd4-3f233a933ea0",
  "conversation_message_id": "5592edf6-d7ae-4198-962e-3e8ebe83bb49",
  "conversation_id": "fa3efd47-67b2-428c-b75f-17d1dd2e9790",
  "status": "queued",
  "contact_id": "ff506e53-e2f0-4d92-884a-dab77d628bad",
  "contact_created": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/templatestemplates:writeTry in console

Submit template

Submit a WhatsApp template for Meta approval through a connected WABA.

Body parameters
namerequiredstring
categoryrequiredstring
languagestring
bodyrequiredstring
  • Only approved templates can be used for outbound WhatsApp sends.
  • name must match ^[a-z0-9_]+$ (lowercase letters, numbers and underscores only); category is one of marketing, utility, authentication; language defaults to en_US.
  • Optional footer (≤60 chars) and up to 3 buttons, each { type: "url", text, url }, { type: "phone", text, phone } or { type: "quick_reply", text }.
  • channel_id chooses which connected WhatsApp number submits the template; omit it to use the workspace's connected number.
  • The template is returned flat (fields at the top level), matching GET /api/v1/templates/{id}.
  • Category is what the message does: utility only for updates about something the customer already has, with no promotional wording (Meta rejects it or moves it to marketing), marketing for anything promotional, authentication for codes. Number variables {{1}}, {{2}} and on, in order with none skipped.
  • You do not send sample values. Meta's reviewers need a sample for every variable, so Blueticked sends one per variable, chosen from the words around it (a first name after Hi, a date after on). To pick your own samples, create the template in the dashboard.
  • The API creates templates without a header (a body, with an optional footer and buttons). A template with a DOCUMENT header (a statement or an invoice) is made in the dashboard, then sent with document on POST /api/v1/messages or document_source on a campaign. A body carrying a header field (any field whose name starts with header, in any case: header, header_format, headerFormat) answers 422 template_kind_unsupported with details.field naming it, and nothing is created.
curl -X POST "https://app.blueticked.com/api/v1/templates" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "name": "order_update_v1",
  "category": "utility",
  "language": "en_US",
  "body": "Hi {{1}}, your order {{2}} is ready."
}'
Response
{
  "id": "7af1270d-94ba-4661-9fc4-0d3852469977",
  "name": "order_update_v1",
  "category": "utility",
  "language": "en_US",
  "status": "pending",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
GET/api/v1/templatestemplates:readTry in console

List templates

List WhatsApp templates and their approval status, with cursor pagination.

Query parameters
limitstring
  • Filter by ?status= to find approved templates ready to send.
  • Page with ?limit= (max 200, default 50) and ?cursor= (the previous page's next_cursor).
curl -X GET "https://app.blueticked.com/api/v1/templates?limit=50" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "data": [
    {
      "id": "7af1270d-94ba-4661-9fc4-0d3852469977",
      "name": "order_update_v1",
      "category": "utility",
      "language": "en_US",
      "status": "approved"
    }
  ],
  "next_cursor": null,
  "has_more": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
GET/api/v1/templates/{id}templates:readTry in console

Get a template

Fetch one template, including its body, buttons, and rejection reason if any.

  • Poll this endpoint (or list templates) to track review progress. status moves from pending to approved/rejected as Meta's review completes. There is no template.* webhook event.
curl -X GET "https://app.blueticked.com/api/v1/templates/{id}" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "id": "7af1270d-94ba-4661-9fc4-0d3852469977",
  "channel_id": "1891a644-c71d-4a79-b679-6db1db14e065",
  "name": "order_update_v1",
  "category": "utility",
  "language": "en_US",
  "body": "Hi {{1}}, your order {{2}} is ready.",
  "footer": null,
  "buttons": [],
  "status": "approved",
  "external_id": "1234567890",
  "rejection_reason": null,
  "submitted_at": "2026-05-20T08:00:00.000Z",
  "approved_at": "2026-05-20T09:00:00.000Z",
  "created_at": "2026-05-20T08:00:00.000Z",
  "updated_at": "2026-05-20T09:00:00.000Z",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/templates/{id}/submittemplates:writeTry in console

Submit a draft template

Send an existing draft to Meta for review. The draft itself is submitted, so no second template is created.

  • Use it for a draft saved in the dashboard, or one POST /api/v1/templates left at draft when Meta refused it: correct the draft in the dashboard, then submit it here. POST /api/v1/templates always creates a new template, so retrying that call leaves the old draft behind.
  • The draft is checked the way the dashboard checks it before anything reaches Meta; a problem answers 400 validation_failed with what to change. A template that is not a draft (pending, approved, rejected, paused) answers 409 conflict with its status in details; to change a submitted template, create a new one. A draft that another submit is sending right now also answers 409 conflict (details.reason template_not_draft): only one submit of a draft reaches Meta. With the same Idempotency-Key, a retry of a success gets the first answer back.
  • It goes through the draft's own WhatsApp number, or the connected number of its business when it has none; a number that is not connected answers 503 channel_unavailable. Meta refusing the template answers 502 provider_error with Meta's words.
  • The response is the template with status: "pending"; poll GET /api/v1/templates/{id} for Meta's decision. A test key runs every check and stops before Meta: the draft is unchanged and the response carries test_mode: true and submitted_to_meta: false.
curl -X POST "https://app.blueticked.com/api/v1/templates/{id}/submit" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "id": "7af1270d-94ba-4661-9fc4-0d3852469977",
  "channel_id": "1891a644-c71d-4a79-b679-6db1db14e065",
  "name": "order_update_v1",
  "category": "utility",
  "language": "en_US",
  "body": "Hi {{1}}, your order {{2}} is ready.",
  "footer": null,
  "buttons": [],
  "status": "pending",
  "external_id": "1234567890",
  "rejection_reason": null,
  "submitted_at": "2026-05-20T08:00:00.000Z",
  "approved_at": null,
  "created_at": "2026-05-19T16:00:00.000Z",
  "updated_at": "2026-05-20T08:00:00.000Z",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/flowsflows:writeTry in console

Create Flow draft

Create a WhatsApp Flow draft from a definition in Blueticked's builder dialect.

Body parameters
namerequiredstring
descriptionstring
definitionobject
  • definition is Blueticked's builder dialect, not raw Meta Flow JSON: { version, screens: [...] } where each screen is { id, title, terminal?, layout: { type: "SingleColumnLayout", children: [...] } }. Screen ids use letters, digits and underscores.
  • Supported component types: TextInput, TextArea, DatePicker, CalendarPicker, Dropdown, RadioButtonsGroup, CheckboxGroup, ChipsSelector, OptIn, PhotoPicker, DocumentPicker, Image, TextHeading, TextSubheading, TextBody, TextCaption, RichText, EmbeddedLink, NavigationList, If, Switch and Footer (an explicit Form wrapper is also accepted). Anything else (e.g. Meta-only DateTimePicker or ImageCarousel) is rejected with 400 validation_failed at create/update time.
  • Input components carry name, label and optional required; choice fields take data-source as a static [{ id, title }] list (or a ${data.key} webhook binding on dynamic forms); each screen ends in a Footer whose on-click-action.name is complete (terminal screen), navigate or data_exchange.
  • On publish the stored dialect is transformed to Meta Flow JSON 7.3: the wire version is stamped, screens are wrapped in a Form, and the complete/navigate payload mappings Meta requires are generated, so never paste Meta Flow JSON here. Deeper rules (label caps, footer/payload rules) are checked at publish with friendly errors.
  • Omit definition (or send {}) to create a blank draft and fill it in via PATCH or the Flow builder UI.
  • The flow is returned flat (fields at the top level, the full serializer), matching GET /api/v1/flows/{id}.
curl -X POST "https://app.blueticked.com/api/v1/flows" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "name": "customer_feedback_v1",
  "description": "Post-visit feedback form",
  "definition": {
    "version": "7.3",
    "screens": [
      {
        "id": "MAIN",
        "title": "Feedback",
        "terminal": true,
        "layout": {
          "type": "SingleColumnLayout",
          "children": [
            {
              "type": "TextHeading",
              "text": "How did Karoo Trading Co do?"
            },
            {
              "type": "RadioButtonsGroup",
              "name": "rating",
              "label": "Your rating",
              "required": true,
              "data-source": [
                {
                  "id": "great",
                  "title": "Great"
                },
                {
                  "id": "okay",
                  "title": "Okay"
                },
                {
                  "id": "poor",
                  "title": "Poor"
                }
              ]
            },
            {
              "type": "TextInput",
              "name": "feedback",
              "label": "Anything to add?",
              "helper-text": "A sentence or two is perfect."
            },
            {
              "type": "Footer",
              "label": "Submit",
              "on-click-action": {
                "name": "complete"
              }
            }
          ]
        }
      }
    ]
  }
}'
Response
{
  "id": "2bb7e214-6106-4d25-ba1f-2e28b88dc657",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "name": "customer_feedback_v1",
  "description": "Post-visit feedback form",
  "status": "draft",
  "preset_id": null,
  "meta_flow_id": null,
  "meta_flow_version": null,
  "definition": {
    "version": "7.3",
    "screens": [
      {
        "id": "MAIN",
        "title": "Feedback",
        "terminal": true,
        "layout": {
          "type": "SingleColumnLayout",
          "children": [
            {
              "type": "TextHeading",
              "text": "How did Karoo Trading Co do?"
            },
            {
              "type": "RadioButtonsGroup",
              "name": "rating",
              "label": "Your rating",
              "required": true,
              "data-source": [
                {
                  "id": "great",
                  "title": "Great"
                },
                {
                  "id": "okay",
                  "title": "Okay"
                },
                {
                  "id": "poor",
                  "title": "Poor"
                }
              ]
            },
            {
              "type": "TextInput",
              "name": "feedback",
              "label": "Anything to add?",
              "helper-text": "A sentence or two is perfect."
            },
            {
              "type": "Footer",
              "label": "Submit",
              "on-click-action": {
                "name": "complete"
              }
            }
          ]
        }
      }
    ]
  },
  "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq...\n-----END PUBLIC KEY-----\n",
  "last_synced_at": null,
  "created_at": "2026-05-22T10:30:00.000Z",
  "updated_at": "2026-05-22T10:30:00.000Z",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
POST/api/v1/flows/{id}/publishflows:writeTry in console

Publish Flow

Publish a valid WhatsApp Flow draft to Meta.

Body parameters
channel_idstring
  • Publishing requires a connected WABA and valid Flow definition.
  • channel_id (optional) picks which connected WhatsApp number the Flow publishes through. Resolution order: your explicit channel_id, then the channel the Flow was last published with, then the first connected WhatsApp channel. The Flow is pinned to the resolved number's Flows encryption key (dynamic forms decrypt against it), so multi-number workspaces should always pass it.
  • This route honours the Idempotency-Key header. Publish is the module's most side-effectful call (a republish clones a NEW Meta flow), and a retried request with the same key replays the first response for 24 hours instead of cloning again.
  • The response is the full flow row plus a nested publish object: on a republish republished is true and previous_meta_flow_id / previous_deprecated / templates_referencing_previous / automations_* report what happened to the superseded flow and everything that referenced it.
  • A blu_test_ (sandbox) key never publishes to Meta: it returns a dry-run shape ({ id, name, status, test_mode: true, published: false, publish: { simulated: true, would_publish: true, republished } }) with the local row left unpublished.
curl -X POST "https://app.blueticked.com/api/v1/flows/{id}/publish" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-me-per-request-0001" \
  -d '{
  "channel_id": "33333333-3333-4333-9333-333333333333"
}'
Response
{
  "organization_id": "5b8ec566-3d60-45a0-b4ad-688c9386f289",
  "business_id": "8070c984-41f6-4bfa-900b-1ce8750ad439",
  "definition_updated_at": "2026-05-22T10:30:00.000Z",
  "id": "2bb7e214-6106-4d25-ba1f-2e28b88dc657",
  "name": "customer_feedback_v1",
  "description": "Post-visit feedback form",
  "status": "published",
  "meta_flow_id": "4164742601298957",
  "meta_flow_version": 1,
  "definition": {
    "version": "7.3",
    "screens": [
      {
        "id": "MAIN",
        "title": "Feedback",
        "terminal": true,
        "layout": {
          "type": "SingleColumnLayout",
          "children": [
            {
              "type": "TextHeading",
              "text": "How did Karoo Trading Co do?"
            },
            {
              "type": "RadioButtonsGroup",
              "name": "rating",
              "label": "Your rating",
              "required": true,
              "data-source": [
                {
                  "id": "great",
                  "title": "Great"
                },
                {
                  "id": "okay",
                  "title": "Okay"
                },
                {
                  "id": "poor",
                  "title": "Poor"
                }
              ]
            },
            {
              "type": "TextInput",
              "name": "feedback",
              "label": "Anything to add?",
              "helper-text": "A sentence or two is perfect."
            },
            {
              "type": "Footer",
              "label": "Submit",
              "on-click-action": {
                "name": "complete"
              }
            }
          ]
        }
      }
    ]
  },
  "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq...\n-----END PUBLIC KEY-----\n",
  "last_synced_at": "2026-05-22T10:35:00.000Z",
  "created_at": "2026-05-22T10:30:00.000Z",
  "updated_at": "2026-05-22T10:35:00.000Z",
  "publish": {
    "republished": false,
    "resumed": false,
    "previous_meta_flow_id": null,
    "previous_deprecated": false,
    "templates_referencing_previous": [],
    "automations_relinked": [],
    "automations_relink_failed": [],
    "automations_referencing_previous": []
  },
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
GET/api/v1/flowsflows:readTry in console

List Flows

List WhatsApp Flows and their publish status, with cursor pagination.

Query parameters
limitstring
statusstring
  • Filter with ?status=: one of draft, published, deprecated, blocked or throttled. Omit it to list every Flow.
  • Page with ?limit= (max 200, default 50) and ?cursor= (the previous page's next_cursor).
  • Use the Flow builder UI for visual editing.
curl -X GET "https://app.blueticked.com/api/v1/flows?limit=50&status=published" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "data": [
    {
      "id": "2bb7e214-6106-4d25-ba1f-2e28b88dc657",
      "business_id": "22222222-2222-4222-9222-222222222222",
      "name": "customer_feedback_v1",
      "description": "Post-visit feedback form",
      "status": "published",
      "preset_id": null,
      "meta_flow_id": "4164742601298957",
      "meta_flow_version": 1,
      "definition": {
        "version": "7.3",
        "screens": [
          {
            "id": "MAIN",
            "title": "Feedback",
            "terminal": true,
            "layout": {
              "type": "SingleColumnLayout",
              "children": [
                {
                  "type": "TextHeading",
                  "text": "How did Karoo Trading Co do?"
                },
                {
                  "type": "RadioButtonsGroup",
                  "name": "rating",
                  "label": "Your rating",
                  "required": true,
                  "data-source": [
                    {
                      "id": "great",
                      "title": "Great"
                    },
                    {
                      "id": "okay",
                      "title": "Okay"
                    },
                    {
                      "id": "poor",
                      "title": "Poor"
                    }
                  ]
                },
                {
                  "type": "TextInput",
                  "name": "feedback",
                  "label": "Anything to add?",
                  "helper-text": "A sentence or two is perfect."
                },
                {
                  "type": "Footer",
                  "label": "Submit",
                  "on-click-action": {
                    "name": "complete"
                  }
                }
              ]
            }
          }
        ]
      },
      "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq...\n-----END PUBLIC KEY-----\n",
      "last_synced_at": "2026-05-22T10:35:00.000Z",
      "created_at": "2026-05-22T10:30:00.000Z",
      "updated_at": "2026-05-22T10:35:00.000Z"
    }
  ],
  "next_cursor": null,
  "has_more": false,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
GET/api/v1/flows/{id}flows:readTry in console

Get a Flow

Fetch one Flow, including its full definition.

curl -X GET "https://app.blueticked.com/api/v1/flows/{id}" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "id": "2bb7e214-6106-4d25-ba1f-2e28b88dc657",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "name": "customer_feedback_v1",
  "description": "Post-visit feedback form",
  "status": "published",
  "preset_id": null,
  "meta_flow_id": "4164742601298957",
  "meta_flow_version": 1,
  "definition": {
    "version": "7.3",
    "screens": [
      {
        "id": "MAIN",
        "title": "Feedback",
        "terminal": true,
        "layout": {
          "type": "SingleColumnLayout",
          "children": [
            {
              "type": "TextHeading",
              "text": "How did Karoo Trading Co do?"
            },
            {
              "type": "RadioButtonsGroup",
              "name": "rating",
              "label": "Your rating",
              "required": true,
              "data-source": [
                {
                  "id": "great",
                  "title": "Great"
                },
                {
                  "id": "okay",
                  "title": "Okay"
                },
                {
                  "id": "poor",
                  "title": "Poor"
                }
              ]
            },
            {
              "type": "TextInput",
              "name": "feedback",
              "label": "Anything to add?",
              "helper-text": "A sentence or two is perfect."
            },
            {
              "type": "Footer",
              "label": "Submit",
              "on-click-action": {
                "name": "complete"
              }
            }
          ]
        }
      }
    ]
  },
  "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq...\n-----END PUBLIC KEY-----\n",
  "last_synced_at": "2026-05-22T10:35:00.000Z",
  "created_at": "2026-05-22T10:30:00.000Z",
  "updated_at": "2026-05-22T10:35:00.000Z",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
PATCH/api/v1/flows/{id}flows:writeTry in console

Update a Flow

Update a Flow's name, description, or definition.

Body parameters
namestring
definitionobject
  • definition is Blueticked's builder dialect, not raw Meta Flow JSON: { version, screens: [...] } where each screen is { id, title, terminal?, layout: { type: "SingleColumnLayout", children: [...] } }. Screen ids use letters, digits and underscores.
  • Supported component types: TextInput, TextArea, DatePicker, CalendarPicker, Dropdown, RadioButtonsGroup, CheckboxGroup, ChipsSelector, OptIn, PhotoPicker, DocumentPicker, Image, TextHeading, TextSubheading, TextBody, TextCaption, RichText, EmbeddedLink, NavigationList, If, Switch and Footer (an explicit Form wrapper is also accepted). Anything else (e.g. Meta-only DateTimePicker or ImageCarousel) is rejected with 400 validation_failed at create/update time.
  • Input components carry name, label and optional required; choice fields take data-source as a static [{ id, title }] list (or a ${data.key} webhook binding on dynamic forms); each screen ends in a Footer whose on-click-action.name is complete (terminal screen), navigate or data_exchange.
  • On publish the stored dialect is transformed to Meta Flow JSON 7.3: the wire version is stamped, screens are wrapped in a Form, and the complete/navigate payload mappings Meta requires are generated, so never paste Meta Flow JSON here. Deeper rules (label caps, footer/payload rules) are checked at publish with friendly errors.
  • Published Flows can be edited too: the change is stored locally and marked as unsynced drift, and reaches WhatsApp only when you POST /api/v1/flows/{id}/publish again, which follows Meta's immutability rules by publishing a NEW Meta flow (meta_flow_id changes; templates and Flow Studio flows that referenced the old one are reconciled and reported in the publish response).
curl -X PATCH "https://app.blueticked.com/api/v1/flows/{id}" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "customer_feedback_v2",
  "definition": {
    "version": "7.3",
    "screens": [
      {
        "id": "MAIN",
        "title": "Feedback",
        "terminal": true,
        "layout": {
          "type": "SingleColumnLayout",
          "children": [
            {
              "type": "TextHeading",
              "text": "How did Karoo Trading Co do?"
            },
            {
              "type": "RadioButtonsGroup",
              "name": "rating",
              "label": "Your rating",
              "required": true,
              "data-source": [
                {
                  "id": "great",
                  "title": "Great"
                },
                {
                  "id": "okay",
                  "title": "Okay"
                },
                {
                  "id": "poor",
                  "title": "Poor"
                }
              ]
            },
            {
              "type": "TextInput",
              "name": "feedback",
              "label": "Anything to add?",
              "helper-text": "A sentence or two is perfect."
            },
            {
              "type": "Footer",
              "label": "Submit",
              "on-click-action": {
                "name": "complete"
              }
            }
          ]
        }
      }
    ]
  }
}'
Response
{
  "id": "2bb7e214-6106-4d25-ba1f-2e28b88dc657",
  "business_id": "22222222-2222-4222-9222-222222222222",
  "name": "customer_feedback_v2",
  "description": "Post-visit feedback form",
  "status": "draft",
  "preset_id": null,
  "meta_flow_id": null,
  "meta_flow_version": null,
  "definition": {
    "version": "7.3",
    "screens": [
      {
        "id": "MAIN",
        "title": "Feedback",
        "terminal": true,
        "layout": {
          "type": "SingleColumnLayout",
          "children": [
            {
              "type": "TextHeading",
              "text": "How did Karoo Trading Co do?"
            },
            {
              "type": "RadioButtonsGroup",
              "name": "rating",
              "label": "Your rating",
              "required": true,
              "data-source": [
                {
                  "id": "great",
                  "title": "Great"
                },
                {
                  "id": "okay",
                  "title": "Okay"
                },
                {
                  "id": "poor",
                  "title": "Poor"
                }
              ]
            },
            {
              "type": "TextInput",
              "name": "feedback",
              "label": "Anything to add?",
              "helper-text": "A sentence or two is perfect."
            },
            {
              "type": "Footer",
              "label": "Submit",
              "on-click-action": {
                "name": "complete"
              }
            }
          ]
        }
      }
    ]
  },
  "public_key": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq...\n-----END PUBLIC KEY-----\n",
  "last_synced_at": null,
  "created_at": "2026-05-22T10:30:00.000Z",
  "updated_at": "2026-05-22T11:00:00.000Z",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
DELETE/api/v1/flows/{id}flows:writeTry in console

Delete a Flow

Soft-delete a Flow so it no longer appears in the workspace.

  • Idempotent: deleting an already-deleted Flow still returns deleted: true.
curl -X DELETE "https://app.blueticked.com/api/v1/flows/{id}" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "deleted": true,
  "id": "2bb7e214-6106-4d25-ba1f-2e28b88dc657",
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
GET/api/v1/channels/whatsapp/readinesschannels:readTry in console

WhatsApp readiness

Check WABA connection, template, test send, reply and webhook readiness.

  • Run this before live pilots and after Meta account changes.
  • Overall status is "ready", "needs_attention" (warnings only) or "blocked" (at least one failing check).
  • Each check reports status "ok", "warn" or "fail" with keys channel_connected, channel_credentials, approved_templates, recent_outbound and recent_inbound.
  • action_url and each channel's settings_url are in-dashboard deep links: the account area is /settings, a specific number is /settings/whatsapp/{wabaId} (falling back to /settings when no WABA id is known), templates live at /templates, a pilot send at /whatsapp/campaigns/new and inbound proof at /conversations.
  • Pass ?channel_id= to scope the snapshot to a single WhatsApp channel; an unknown id returns 404 not_found.
  • Gate launch flow on status === "ready" (or accept needs_attention if warnings are acceptable).
curl -X GET "https://app.blueticked.com/api/v1/channels/whatsapp/readiness" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "status": "blocked",
  "checks": [
    {
      "key": "channel_connected",
      "label": "WhatsApp channel connected",
      "status": "ok",
      "message": "1 connected WhatsApp channel found.",
      "action_url": "/settings"
    },
    {
      "key": "approved_templates",
      "label": "Approved templates",
      "status": "fail",
      "message": "Create and submit at least one WhatsApp template.",
      "action_url": "/templates"
    }
  ],
  "summary": {
    "connected_channels": 1,
    "approved_templates": 0,
    "pending_templates": 1,
    "rejected_templates": 0,
    "recent_outbound": 0,
    "recent_successful_outbound": 0,
    "recent_inbound": 0
  },
  "channels": [
    {
      "id": "1891a644-c71d-4a79-b679-6db1db14e065",
      "business_id": "8070c984-41f6-4bfa-900b-1ce8750ad439",
      "display_name": "Main number",
      "identifier": "+27210000000",
      "status": "connected",
      "connected_at": "2026-07-01T08:00:00Z",
      "last_error": null,
      "settings_url": "/settings/whatsapp/1234567890"
    }
  ],
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}
GET/api/v1/channels/{id}/whatsapp-spendchannels:readTry in console

WhatsApp spend

What Meta billed the business for WhatsApp on one channel, by category and country, in Meta's currency.

Query parameters
daysstring
  • Meta bills the business directly for WhatsApp; Blueticked never resells or meters it. This is Meta's own pricing analytics for the channel's WhatsApp Business Account, so currency is the currency Meta bills in, and the figures are approximate until Meta's billing settles.
  • days is a whole number from 1 to 90 (default 30); the window ends now. Anything else is 400 validation_failed.
  • cost is what Meta charged: 0 for free messages (a customer-service reply, for one), and null only where Meta reports messages without a cost. Lines are ordered by cost, highest first.
  • An id that is not a UUID is 400 validation_failed. A channel that is not WhatsApp is 400 validation_failed with details.reason "not_whatsapp_channel"; one with no Meta connection, or whose token Meta refuses, is 503 channel_unavailable ("whatsapp_token_missing": reconnect it, retrying will not help). When Meta fails or cannot be reached the answer is 502 provider_error or 503 service_unavailable ("meta_unavailable"), never an empty spend.
  • Each channel can be read 30 times a minute, whichever key asks; a key confined to other businesses gets 404 not_found, as for another workspace's channel.
curl -X GET "https://app.blueticked.com/api/v1/channels/{id}/whatsapp-spend?days=30" \
  -H "Authorization: Bearer $BLUETICKED_API_KEY"
Response
{
  "channel_id": "1891a644-c71d-4a79-b679-6db1db14e065",
  "window": {
    "days": 30,
    "start": "2026-09-18T09:00:00.000Z",
    "end": "2026-10-18T09:00:00.000Z"
  },
  "currency": "USD",
  "totals": {
    "messages": 1240,
    "cost": 31.85
  },
  "by_category": [
    {
      "category": "marketing",
      "messages": 820,
      "cost": 28.7
    },
    {
      "category": "utility",
      "messages": 300,
      "cost": 3.15
    },
    {
      "category": "service",
      "messages": 120,
      "cost": 0
    }
  ],
  "by_country": [
    {
      "country": "ZA",
      "messages": 1180,
      "cost": 30.4
    },
    {
      "country": "BW",
      "messages": 60,
      "cost": 1.45
    }
  ],
  "source": "meta_pricing_analytics",
  "approximate": true,
  "request_id": "req_b2e3d610e10f1b07468721ee98f0e1d2"
}

What your customer sees

Meta's Embedded Signup, in a popup.

Five screens, about five minutes if nothing needs verifying.

  1. Facebook login. Their personal Facebook account, the one with admin rights on the business. Not a Blueticked login; there is no Blueticked account involved anywhere in this flow.
  2. Business selection. Choose an existing Meta Business account or create one. A brand-new one triggers verification later, which is the slow path.
  3. WhatsApp Business Account. Create one or pick an existing WABA.
  4. Phone number. A number that can receive an SMS or a call right now, and that is not already registered to the WhatsApp app.
  5. Display name. The name recipients will see. Reviewed by Meta.

Where it goes wrong

In rough order of how often we see it.

The number is already on WhatsApp
By far the most common failure. A number in use by the consumer WhatsApp app or WhatsApp Business app cannot be registered to the API until it is deleted from that app, which erases its chat history. Decide this BEFORE starting, and use a fresh number if the history matters.
Nobody can receive the verification code
The code goes to that phone by SMS or voice call. A number that only lives in a desk phone system, or one whose SIM is in a drawer, stalls the flow at the last screen.
The display name reads like an advert
Meta requires it to be the business name. Names with slogans, offers or extra words are rejected, and a rejection costs another review cycle. Use the registered trading name.
The person doing it is not an admin
They need admin rights on the Meta Business account. A staff member with partial access reaches the business-selection screen and cannot continue, usually without a clear reason.
Business verification is required
A new or unverified business must submit documents proving it is real. This is days, not minutes, and nothing anyone does shortens the queue. It does not block testing, only higher messaging limits and some capabilities.
They closed the popup
Meta's window closes and nothing is saved. The connect link is single-use but only burns on SUCCESS, so they can open the same link again. That is deliberate, precisely so a half-finished attempt does not strand them.

After they finish

You get a channel.connected webhook carrying the reference you set when minting the link, so you can match it to your own customer record. Sending still needs an approved template and a funded wallet. Going live has the full sequence.

The simplest correct button

You do not need a WhatsApp logo to start a connect flow.

“Connect WhatsApp” as plain text, styled as your own product's button, is compliant, matches your interface, and cannot go stale. Reach for a mark only if you have read Meta's guidelines and want one.

Markup
<!-- No WhatsApp mark required. Your product's own styling,
     wording that names the action rather than the brand. -->
<button class="your-button" onclick="startConnect()">
  Connect WhatsApp
</button>
What it does
async function startConnect() {
  // Your server mints the link — never expose an API key to the browser.
  const res = await fetch("/api/whatsapp/connect-link", { method: "POST" });
  const { url } = await res.json();
  window.location.href = url;   // or open in a new tab
}

If you do use Meta's marks

The rules that get integrations flagged when broken.

Never alter the mark
No recolouring, rotating, stretching, adding effects, or placing it inside another shape. Use it as supplied or not at all.
Never imply endorsement
"Connect WhatsApp" describes an action and is fine. "WhatsApp Partner", "Official WhatsApp", or a WhatsApp logo used as YOUR product's icon claims a relationship you do not have.
Do not use it in your own branding
Not in your app icon, your company name, your domain, or your favicon. This is the one that gets accounts actioned rather than merely asked to change.
Say what it does, not who owns it
Name the outcome for the user: connect an account, send a message. Marks are for identifying the service, not decorating the button.

Referring to Blueticked

Our own marks, which you may use.

"Powered by Blueticked"
Use freely, in plain text or with our wordmark. No permission needed, and no obligation to include it.
Our logo
Available at /bt-logo.png and /bt-icon.webp. Keep clear space around it, do not recolour it, and do not lock it up with another logo to imply a joint product.
What not to say
Do not describe yourself as a Blueticked partner, reseller or official integration unless we have agreed that in writing.

Forms integration

WhatsApp Forms (built on Meta’s WhatsApp Flows) connect to your systems in two ways: get the answers pushed to you when a customer submits, or serve each screen’s data live from your server while they fill it in.

Form submission webhook

When a customer submits a form, the post-submit “Call a webhook” action POSTs the answers to your URL.

Use it to write bookings, leads or intake data into your own system.

POST to your URL
{
  "event": "flow_submission",
  "flow_id": "1f2f3f4f-0000-4000-9000-abcdef123456",
  "organization_id": "22222222-2222-4222-9222-222222222222",
  "submitter_phone": "+27800000001",
  "message_external_id": "wamid.HBgLMjc4MjEyMzQ1NjcVAgA=",
  "response_fields": {
    "service": "checkup",
    "preferred_date": "2026-07-15",
    "preferred_time": "morning",
    "notes": "First visit"
  },
  "timestamp": "2026-07-09T08:30:00.000Z"
}
Your response
{
  "received": true
}
  • Respond with any 2xx within 15 seconds; anything else is retried 3 times with exponential backoff.
  • When you set a secret on the action, every request carries TWO headers: x-blueticked-timestamp (unix seconds) and x-blueticked-signature: sha256=<hex>. The digest is HMAC-SHA256 over `${timestamp}.${rawBody}`: the timestamp, a literal dot, then the exact raw body, NOT the body alone. Verify it before trusting the payload, and reject timestamps outside a few minutes' tolerance to stop replays (the Stripe/GitHub pattern).
  • Note the difference from org-level webhooks (Developers → Webhooks): those carry their timestamp inside x-blueticked-signature-v2 (t=<unix seconds>,v1=<hex HMAC of `${t}.${rawBody}`>), and their x-blueticked-signature is the older body-only signature. Here the timestamp travels in its own header. Verify against the scheme for the surface you subscribed to: a receiver written for one will silently reject every delivery from the other.
  • Endpoints must be public https URLs; private hosts and IP ranges are refused.
  • response_fields keys are the field names you set in the form builder.
  • For production, prefer a saved Integration (Integrations page): field mapping, bearer/API-key auth, a per-delivery log with retry, and health alerts when your endpoint starts failing. The org-level flow.submitted webhook (Developers, Webhooks) is a third option with 8 delivery attempts.

Dynamic form data endpoint

A dynamic form asks YOUR server for each screen's data while the customer is filling it in: live appointment slots, personalised prices.

Blueticked hosts Meta's encrypted endpoint and forwards every action to your webhook as plain JSON; you never touch Meta's crypto.

POST to your data endpoint
{
  "action": "data_exchange",
  "screen": "PICK_SLOT",
  "data": {
    "service": "checkup",
    "preferred_date": "2026-07-15"
  },
  "flow_token": "opaque-session-token",
  "flow_id": "1f2f3f4f-0000-4000-9000-abcdef123456"
}
Your response: name the next screen and its data
{
  "screen": "CONFIRM",
  "data": {
    "slots": [
      {
        "id": "s1",
        "title": "Wed 15 Jul, 09:00"
      },
      {
        "id": "s2",
        "title": "Wed 15 Jul, 11:30"
      }
    ]
  }
}
  • action is INIT (form opened), data_exchange (screen submitted) or BACK; data carries the current screen's field values.
  • Respond with 2xx JSON within 10 seconds. `screen` is required: it names the screen to render next; `data` feeds that screen's dynamic components.
  • When you set an endpoint secret, requests carry X-Blueticked-Signature: sha256=<HMAC-SHA256 hex of the raw body>.
  • If your endpoint times out or errors, the customer sees a retryable “Something went wrong” on their current screen. Nothing is lost, but slow endpoints feel broken. Keep it fast.
  • flow_token is stable for one customer's session: use it to correlate INIT through completion.
  • Enable this per form under the builder's Dynamic panel: endpoint URL, optional secret, then publish.

Recipe: appointments

Blueticked has no native calendar by design: your booking system stays the source of truth.

The form collects the request; your endpoint owns availability and confirmation.

  1. 1Create the form from the “Booking” starting point (Forms, New form): service, preferred date and time, phone. It publishes under Meta's APPOINTMENT_BOOKING category.
  2. 2Simple version: add the “Call a webhook” post-submit action (or a saved Integration) pointing at your system, or at Zapier / Make / n8n if you don't run a server. Book the slot there and confirm manually or via the send-message API.
  3. 3Live-slots version: flip the form to dynamic in the builder's Dynamic panel and serve real availability from your data endpoint, so the customer only ever sees bookable slots.
  4. 4Confirm the booking on WhatsApp with POST /api/v1/messages (template if outside the 24-hour window).
  5. 5Use “Send test request” in the Dynamic panel and the Integration test button to verify your endpoints before publishing.

Verifying signatures

Both contracts sign the raw request body with your secret.

Compare in constant time:

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

export function verifyBlueticked(rawBody, signatureHeader, secret) {
  const expected = "sha256=" +
    createHmac("sha256", secret).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signatureHeader ?? "");
  return a.length === b.length && timingSafeEqual(a, b);
}