{
  "openapi": "3.1.0",
  "info": {
    "title": "Ghala Developer API",
    "summary": "Automate your WhatsApp conversations.",
    "description": "\nAutomate your WhatsApp conversations on Ghala: send messages to your customers, read the\ntemplates you are allowed to send them, and receive what happens next on your own\nendpoint. Sending also takes the AI off that thread, so your integration and the\nassistant never answer over the top of each other.\n\n**Authentication** — send your number's WhatsApp access token as a bearer token:\n\n```\nAuthorization: Bearer <your WhatsApp access token>\n```\n\nThat token is issued by Meta and is the same secret Ghala uses to send on your behalf.\nTreat it like a password: keep it server-side, never ship it in a browser or mobile app,\nand rotate it by reconnecting the number if it is ever exposed. **Reconnecting a number\nissues a new token and invalidates the old one**, so update your integration when you do.\n\nYour token authenticates exactly one connected number. Every endpoint is scoped to it.\n\n**The 24-hour rule** — WhatsApp only allows free-form replies within 24 hours of the\ncustomer's last inbound message. After that, an approved template is the only thing that\ndelivers; anything else is refused with a 409. `GET /templates?sendable=true` is the list\nto offer at that moment, and each entry carries the components Meta approved so you know\nwhich variables to supply.\n",
    "contact": {
      "name": "Ghala / Neurotech Africa",
      "url": "https://ghala.io/"
    },
    "version": "1.0.0"
  },
  "paths": {
    "/messages": {
      "post": {
        "tags": [
          "messages"
        ],
        "summary": "Send a message to a customer",
        "description": "Send text, media (image, document, video, voice note), an interactive message, or\nan approved template from your connected number.\n\nAn interactive message is reply buttons, a list menu, or a URL button. The `id` you\ngive each button or row is what comes back on the `message.received` event when the\ncustomer taps it, so make it something you can act on.\n\n**This pauses the AI agent for that customer**, exactly as a reply from the dashboard\ndoes: if your integration is answering, the assistant must not answer over the top of\nyou. It resumes automatically after the number's takeover window, or immediately if\nthe customer types `BOT`. A number with human-takeover disabled is unaffected.\n\nDelivery is asynchronous — the response tells you the message was accepted, and the\n`message.status` webhook reports sent / delivered / read / failed.\n\n**Retrying is safe if you send an `Idempotency-Key`.** A repeat with the same key\nreplays the original response (marked `Idempotency-Replayed: true`) rather than\nsending again; a failed send releases its key so the same one can be retried.",
        "operationId": "send_message_messages_post",
        "security": [
          {
            "WhatsApp access token": []
          }
        ],
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Send a unique value per message. If the request is retried with the same key we replay the original response instead of messaging the customer twice.",
              "title": "Idempotency-Key"
            },
            "description": "Send a unique value per message. If the request is retried with the same key we replay the original response instead of messaging the customer twice."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendMessageIn"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MessageOut"
                }
              }
            }
          },
          "400": {
            "description": "The template's parameters don't match its approved shape."
          },
          "401": {
            "description": "Missing, unknown, or rotated-away access token."
          },
          "402": {
            "description": "This team's plan does not include API access."
          },
          "409": {
            "description": "Outside the 24-hour window (send a template instead), or a request with this Idempotency-Key is still in flight."
          },
          "422": {
            "description": "Malformed body — including an interactive message WhatsApp would reject (more than 3 buttons, more than 10 list rows, repeated option ids) — or this Idempotency-Key was used for a different request."
          },
          "429": {
            "description": "WhatsApp is rate-limiting this number. Retry after a pause."
          },
          "502": {
            "description": "WhatsApp rejected the message; the reason is in the body."
          }
        }
      }
    },
    "/templates": {
      "get": {
        "tags": [
          "templates"
        ],
        "summary": "List the templates you can send",
        "description": "Every message template on your connected number, newest first.\n\n**Use this before sending outside the 24-hour window.** WhatsApp only allows a\nfree-form message within 24 hours of the customer's last inbound one; after that a\ntemplate is the only thing that delivers. `sendable=true` is the list to offer\nsomebody at that moment.\n\nEach entry carries the `components` Meta approved, so the `{{1}}` placeholders in the\nbody are readable without a second call — supply them as `template_components` on\n`POST /messages`.\n\n`sendable` is computed from the same rule the send path enforces, so a template this\nendpoint calls sendable will not be refused locally for its state. Meta remains the\nfinal word: a template we have not synced yet is reported sendable and left to Meta\nto judge, because a missing sync on our side is not the merchant's problem.",
        "operationId": "list_templates_templates_get",
        "security": [
          {
            "WhatsApp access token": []
          }
        ],
        "parameters": [
          {
            "name": "sendable",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "boolean"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Filter to templates a send would accept right now (`true`), or to the ones it would refuse (`false`). Omit for all of them.",
              "title": "Sendable"
            },
            "description": "Filter to templates a send would accept right now (`true`), or to the ones it would refuse (`false`). Omit for all of them."
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "null"
                }
              ],
              "description": "Pagination cursor from a previous response",
              "title": "Cursor"
            },
            "description": "Pagination cursor from a previous response"
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "maximum": 100,
              "minimum": 1,
              "description": "Page size",
              "default": 25,
              "title": "Limit"
            },
            "description": "Page size"
          }
        ],
        "responses": {
          "200": {
            "description": "Successful Response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaginatedResponse_TemplateOut_"
                }
              }
            }
          },
          "400": {
            "description": "Malformed `cursor`."
          },
          "401": {
            "description": "Missing, unknown, or rotated-away access token."
          },
          "402": {
            "description": "This team's plan does not include API access."
          },
          "422": {
            "description": "Validation Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HTTPValidationError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "HTTPValidationError": {
        "properties": {
          "detail": {
            "items": {
              "$ref": "#/components/schemas/ValidationError"
            },
            "type": "array",
            "title": "Detail"
          }
        },
        "type": "object",
        "title": "HTTPValidationError"
      },
      "InteractiveButton": {
        "properties": {
          "id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Id"
          },
          "title": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Title"
          }
        },
        "type": "object",
        "title": "InteractiveButton"
      },
      "InteractiveButtonIn": {
        "properties": {
          "id": {
            "type": "string",
            "maxLength": 256,
            "minLength": 1,
            "title": "Id",
            "description": "Your own identifier for this option. It comes back on the `message.received` event when the customer taps it, so make it something you can act on."
          },
          "title": {
            "type": "string",
            "maxLength": 20,
            "minLength": 1,
            "title": "Title",
            "description": "The button's label."
          }
        },
        "type": "object",
        "required": [
          "id",
          "title"
        ],
        "title": "InteractiveButtonIn",
        "description": "One tappable reply button."
      },
      "InteractiveCta": {
        "properties": {
          "display_text": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Display Text"
          },
          "url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Url"
          }
        },
        "type": "object",
        "title": "InteractiveCta"
      },
      "InteractiveCtaIn": {
        "properties": {
          "display_text": {
            "type": "string",
            "maxLength": 20,
            "minLength": 1,
            "title": "Display Text",
            "description": "The button's label."
          },
          "url": {
            "type": "string",
            "maxLength": 2000,
            "minLength": 1,
            "title": "Url",
            "description": "Must be http(s)."
          }
        },
        "type": "object",
        "required": [
          "display_text",
          "url"
        ],
        "title": "InteractiveCtaIn",
        "description": "A single button that opens a URL."
      },
      "InteractiveIn": {
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "buttons",
              "list",
              "cta_url"
            ],
            "title": "Type"
          },
          "body": {
            "type": "string",
            "maxLength": 1024,
            "minLength": 1,
            "title": "Body",
            "description": "The message text above the options."
          },
          "footer": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 60
              },
              {
                "type": "null"
              }
            ],
            "title": "Footer"
          },
          "buttons": {
            "anyOf": [
              {
                "items": {
                  "$ref": "#/components/schemas/InteractiveButtonIn"
                },
                "type": "array",
                "maxItems": 3
              },
              {
                "type": "null"
              }
            ],
            "title": "Buttons",
            "description": "For type=buttons. Up to 3."
          },
          "button_text": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 20
              },
              {
                "type": "null"
              }
            ],
            "title": "Button Text",
            "description": "For type=list: the label that opens the menu. Defaults to 'View'."
          },
          "sections": {
            "anyOf": [
              {
                "items": {
                  "$ref": "#/components/schemas/InteractiveSectionIn"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Sections",
            "description": "For type=list. Up to 10 rows in total across all sections."
          },
          "cta": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/InteractiveCtaIn"
              },
              {
                "type": "null"
              }
            ],
            "description": "For type=cta_url."
          }
        },
        "type": "object",
        "required": [
          "type",
          "body"
        ],
        "title": "InteractiveIn",
        "description": "An interactive message: reply buttons, a list menu, or a URL button.\n\nThe shape mirrors the ``interactive`` field you read back on a message, so what you\nsend and what you fetch are the same thing."
      },
      "InteractiveRow": {
        "properties": {
          "id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Id"
          },
          "title": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Title"
          },
          "description": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Description"
          }
        },
        "type": "object",
        "title": "InteractiveRow"
      },
      "InteractiveRowIn": {
        "properties": {
          "id": {
            "type": "string",
            "maxLength": 200,
            "minLength": 1,
            "title": "Id",
            "description": "Returned when the row is tapped."
          },
          "title": {
            "type": "string",
            "maxLength": 24,
            "minLength": 1,
            "title": "Title"
          },
          "description": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 72
              },
              {
                "type": "null"
              }
            ],
            "title": "Description",
            "description": "Optional second line, e.g. a price."
          }
        },
        "type": "object",
        "required": [
          "id",
          "title"
        ],
        "title": "InteractiveRowIn",
        "description": "One selectable row in a list menu."
      },
      "InteractiveSection": {
        "properties": {
          "title": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Title"
          },
          "rows": {
            "items": {
              "$ref": "#/components/schemas/InteractiveRow"
            },
            "type": "array",
            "title": "Rows",
            "default": []
          }
        },
        "type": "object",
        "title": "InteractiveSection"
      },
      "InteractiveSectionIn": {
        "properties": {
          "title": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 24
              },
              {
                "type": "null"
              }
            ],
            "title": "Title"
          },
          "rows": {
            "items": {
              "$ref": "#/components/schemas/InteractiveRowIn"
            },
            "type": "array",
            "maxItems": 10,
            "minItems": 1,
            "title": "Rows"
          }
        },
        "type": "object",
        "required": [
          "rows"
        ],
        "title": "InteractiveSectionIn",
        "description": "A titled group of rows. One unnamed section is fine for a flat menu."
      },
      "MessageInteractiveOut": {
        "properties": {
          "type": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Type"
          },
          "body": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Body"
          },
          "footer": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Footer"
          },
          "buttons": {
            "anyOf": [
              {
                "items": {
                  "$ref": "#/components/schemas/InteractiveButton"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Buttons"
          },
          "button_text": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Button Text"
          },
          "sections": {
            "anyOf": [
              {
                "items": {
                  "$ref": "#/components/schemas/InteractiveSection"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Sections"
          },
          "cta": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/InteractiveCta"
              },
              {
                "type": "null"
              }
            ]
          }
        },
        "type": "object",
        "title": "MessageInteractiveOut",
        "description": "A rendered interactive message flattened for the UI (buttons/rows/cta). All fields are\noptional so an odd stored shape degrades gracefully rather than 500-ing the endpoint."
      },
      "MessageOut": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id"
          },
          "direction": {
            "type": "string",
            "title": "Direction"
          },
          "message_type": {
            "type": "string",
            "title": "Message Type"
          },
          "content": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Content"
          },
          "status": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Status"
          },
          "source": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Source"
          },
          "wa_message_id": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Wa Message Id"
          },
          "media_url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Media Url"
          },
          "media_mime_type": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Media Mime Type"
          },
          "media_filename": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Media Filename"
          },
          "media_duration_ms": {
            "anyOf": [
              {
                "type": "integer"
              },
              {
                "type": "null"
              }
            ],
            "title": "Media Duration Ms"
          },
          "interactive": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/MessageInteractiveOut"
              },
              {
                "type": "null"
              }
            ]
          },
          "sent_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Sent At"
          },
          "delivered_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Delivered At"
          },
          "read_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Read At"
          },
          "played_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Played At"
          },
          "failed_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Failed At"
          },
          "failure_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Failure Reason"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "title": "Created At"
          }
        },
        "type": "object",
        "required": [
          "id",
          "direction",
          "message_type",
          "created_at"
        ],
        "title": "MessageOut"
      },
      "PaginatedResponse_TemplateOut_": {
        "properties": {
          "items": {
            "items": {
              "$ref": "#/components/schemas/TemplateOut"
            },
            "type": "array",
            "title": "Items"
          },
          "next_cursor": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Next Cursor"
          },
          "has_more": {
            "type": "boolean",
            "title": "Has More",
            "default": false
          }
        },
        "type": "object",
        "required": [
          "items"
        ],
        "title": "PaginatedResponse[TemplateOut]"
      },
      "SendMessageIn": {
        "properties": {
          "to": {
            "type": "string",
            "maxLength": 20,
            "minLength": 6,
            "title": "To",
            "description": "The customer's WhatsApp number in international format, e.g. 255712345678."
          },
          "type": {
            "type": "string",
            "enum": [
              "text",
              "template",
              "image",
              "document",
              "video",
              "audio",
              "interactive"
            ],
            "title": "Type",
            "default": "text"
          },
          "text": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 4096
              },
              {
                "type": "null"
              }
            ],
            "title": "Text",
            "description": "For type=text."
          },
          "interactive": {
            "anyOf": [
              {
                "$ref": "#/components/schemas/InteractiveIn"
              },
              {
                "type": "null"
              }
            ],
            "description": "For type=interactive: buttons, a list menu, or a URL button."
          },
          "template_name": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Template Name",
            "description": "For type=template."
          },
          "template_language": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Template Language",
            "description": "e.g. 'en' or 'sw'."
          },
          "template_components": {
            "anyOf": [
              {
                "items": {
                  "additionalProperties": true,
                  "type": "object"
                },
                "type": "array"
              },
              {
                "type": "null"
              }
            ],
            "title": "Template Components",
            "description": "Meta's component list, for a template with variables."
          },
          "media_url": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Media Url",
            "description": "Public HTTPS URL; Meta fetches it. For image/document/video/audio."
          },
          "media_caption": {
            "anyOf": [
              {
                "type": "string",
                "maxLength": 1024
              },
              {
                "type": "null"
              }
            ],
            "title": "Media Caption",
            "description": "For image/document/video. WhatsApp does not allow one on audio."
          },
          "media_filename": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Media Filename",
            "description": "For type=document."
          }
        },
        "type": "object",
        "required": [
          "to"
        ],
        "title": "SendMessageIn",
        "description": "Send one WhatsApp message from your connected number.\n\n``type`` picks which of the field groups below is read. Outside WhatsApp's 24-hour\nwindow only ``template`` is allowed — any other type is refused with a 409 rather\nthan being handed to Meta to fail as error 131047."
      },
      "TemplateOut": {
        "properties": {
          "id": {
            "type": "string",
            "title": "Id",
            "description": "Ghala's id for this template. The same id arrives on the `template.status` event, so a cached list can be updated in place rather than re-fetched."
          },
          "name": {
            "type": "string",
            "title": "Name",
            "description": "Pass this as `template_name` when sending."
          },
          "language": {
            "type": "string",
            "title": "Language",
            "description": "Pass this as `template_language`. Meta models each language as its own template, so the same name can appear more than once in this list."
          },
          "category": {
            "type": "string",
            "title": "Category",
            "description": "MARKETING, UTILITY or AUTHENTICATION."
          },
          "status": {
            "type": "string",
            "title": "Status",
            "description": "Meta's review state, e.g. APPROVED or REJECTED."
          },
          "sendable": {
            "type": "boolean",
            "title": "Sendable",
            "description": "Whether a send would be accepted right now. Not simply `status == 'APPROVED'`: a FLAGGED or LOCKED template still sends, and one we have not synced yet is left to Meta to judge. Branch on this rather than on `status`."
          },
          "unsendable_reason": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Unsendable Reason",
            "description": "Why `sendable` is false, in words you can show a merchant."
          },
          "components": {
            "items": {},
            "type": "array",
            "title": "Components",
            "description": "Meta's component list, as approved. Read the BODY component's `text` for the `{{1}}` placeholders you must supply in `template_components` when sending."
          },
          "quality_score": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ],
            "title": "Quality Score",
            "description": "Meta's rating: GREEN, YELLOW, RED or UNKNOWN. It turns RED before Meta pauses a template, so it is the earliest warning you get."
          },
          "approved_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Approved At"
          },
          "status_changed_at": {
            "anyOf": [
              {
                "type": "string",
                "format": "date-time"
              },
              {
                "type": "null"
              }
            ],
            "title": "Status Changed At"
          }
        },
        "type": "object",
        "required": [
          "id",
          "name",
          "language",
          "category",
          "status",
          "sendable",
          "components"
        ],
        "title": "TemplateOut",
        "description": "One approved-or-not message template on the connected number.\n\nA deliberately narrower shape than the dashboard's: an integration needs what it\ntakes to send — the name, the language, and the component list that says which\nvariables the body expects — plus enough to explain a template it may not send. What\nit does not need is our submission bookkeeping."
      },
      "ValidationError": {
        "properties": {
          "loc": {
            "items": {
              "anyOf": [
                {
                  "type": "string"
                },
                {
                  "type": "integer"
                }
              ]
            },
            "type": "array",
            "title": "Location"
          },
          "msg": {
            "type": "string",
            "title": "Message"
          },
          "type": {
            "type": "string",
            "title": "Error Type"
          },
          "input": {
            "title": "Input"
          },
          "ctx": {
            "type": "object",
            "title": "Context"
          }
        },
        "type": "object",
        "required": [
          "loc",
          "msg",
          "type"
        ],
        "title": "ValidationError"
      }
    },
    "securitySchemes": {
      "WhatsApp access token": {
        "type": "http",
        "description": "The access token of the connected WhatsApp number you want to act as — the same token Meta issued when the number was connected. Server-side only; reconnecting the number issues a new one and invalidates this.",
        "scheme": "bearer"
      }
    }
  },
  "servers": [
    {
      "url": "https://v2.ghala.io/api/v2",
      "description": "Production"
    }
  ]
}