G GoUltra API Reference Dashboard Get an API key
REST API - v1

GoUltra API

One unified, secure REST API to run WhatsApp for your business: send approved-template and session messages, read and reply in your inbox, launch campaigns, manage contacts, bookings and quotes, and receive real-time webhooks.

Base URL

https://api.goultra.ai/v1/

Format

JSON over HTTPS. Every response carries a request_id; every error follows one consistent envelope.

Auth

A single Authorization: Bearer <API_KEY> header. Create keys in the Dashboard under API.

Built for scale

Idempotency keys, per-key rate limits, quota + opt-out enforcement, and signed webhooks - the same guardrails that power the GoUltra product.

The contract, machine-readable

The API describes itself in three documents. They need no key, are the same for everyone, answer GET and HEAD, and your client may keep them for 5 minutes (Cache-Control: private, max-age=300: each answer carries its own X-Request-Id, so a shared cache must not keep one for everyone):

  • GET https://api.goultra.ai/v1/openapi.json: an OpenAPI 3.1 document of every route in this reference: parameters, request bodies, answers (each with an example the code itself answered), the error envelope, the headers, the scopes of each operation (x-scopes), the error codes each one can answer (x-error-codes), and the webhook events with their payloads (webhooks). Generate a client from it, or import it into your tools.
  • GET https://api.goultra.ai/v1/postman.json: a Postman collection built from that document. Import it, then set the apiKey variable (and baseUrl if you need another host). Each request that takes a body opens with the example of the document: the request the code answered. The bulk redelivery sets its since when it sends, to one hour before, so it is never older than the 7 days the endpoint takes.
  • GET https://api.goultra.ai/v1/errors: the error catalog: every code, its HTTP status, what it means, whether a retry is safe and when, and what to do (see The error catalog).
  • And as before, GET https://api.goultra.ai/v1: the index of the modules and their endpoints, without a key.
curl -s https://api.goultra.ai/v1/openapi.json -o goultra-openapi.json
curl -s https://api.goultra.ai/v1/postman.json -o goultra.postman_collection.json
curl -s https://api.goultra.ai/v1/errors

The index, the OpenAPI document, the Postman collection and the Reference of this page are built from one list, and that list is checked against the routes the API serves: none of them names a route the API does not serve.

What every answer carries

  • X-Request-Id (since v2.9.398): the id of the answer. On a JSON answer it is the request_id of the body, character for character (in an error, error.request_id); an answer whose body has none (the index, the three documents above, a file from GET /v1/media/:key) has one of its own. Log it, and quote it to support.
  • GoUltra-Version (since v2.9.398): the dated revision of the API that answered, 2026-10-21: the date of the newest entry of the Changelog. It is informational: log it to know which revision answered a request. Nothing reads it from a request; there is no pinning to a revision.
  • X-API-Version: 1.0.0, the major version of the contract, unchanged since the API opened. It would change only with a change that is not additive; every change so far has been additive.
  • X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset: where your key stands in its minute (Reading where you stand); Retry-After on a 429 and on the 503s that say their wait; Idempotent-Replayed: true on a replay (Idempotency).

A web page may read X-Request-Id, GoUltra-Version, Retry-After, the X-RateLimit headers and Idempotent-Replayed: Access-Control-Expose-Headers names the ones an answer carries. Keys stay on your server (Authentication), so this matters for the routes without a key.

Every answer of the /v1 router carries X-Request-Id and GoUltra-Version. The CORS preflight (OPTIONS) is answered before the router: it carries neither X-Request-Id nor GoUltra-Version, and on a /v1 path it allows the request headers Content-Type, X-API-Key, Authorization and Idempotency-Key, and the methods GET, POST, PUT, DELETE, OPTIONS and, since v2.9.400, PATCH (so a web page can call PATCH /v1/contacts/:id and PATCH /v1/webhooks/:id).

One answer comes before the API itself: while GoUltra is paused for an incident, every request gets 503 with a Retry-After header and the body { "success": false, "error": "maintenance", "error_code": "MAINTENANCE", "retry_after_seconds", "message" }, not the error envelope, and without the headers above. Retry after the wait.

Quickstart

Grab your API key from the GoUltra Dashboard (the API area shows the key for the WhatsApp number selected in the sidebar), then send your first approved-template message:

curl https://api.goultra.ai/v1/messages \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9182-confirmed" \
  -d '{
    "to": "+14155551234",
    "template": "order_confirmed",
    "language": "en",
    "params": ["Jordan", "#9182"]
  }'

See Messages for the full request and response, and Compliance and opt-out for the rules every send follows.

Test vs live. Keys are labelled test or live. A test key is read-only: it reads what its scopes allow, and the one thing it can send is a test event to your own webhook endpoint. A call that would deliver a WhatsApp message to a person is refused with test_key_cannot_send: sending, campaigns, inbox replies, bookings (which schedule a reminder) and one-time codes. Any other call that would change your account is refused with test_key_read_only: creating, changing or deleting a contact, a template or a webhook endpoint. Its accepted requests count toward the hourly limit in Rate limits; a refused one does not. The key shown for a number in the API area is a live key. See Keys and scopes.

Send a template in four languages

The request above in curl, and the same request in Node.js, Python and PHP. Each sends an Idempotency-Key, so a retry after a timeout never sends twice, and prints the status, the X-Request-Id header and the answer. Put your key in the environment variable GOULTRA_API_KEY; never in code a browser or an app receives.

Node.js

// Node.js 18 or later - send a template (no package needed). Save as send.js, run: node send.js
async function main() {
  const res = await fetch("https://api.goultra.ai/v1/messages", {
    method: "POST",
    headers: {
      "Authorization": "Bearer " + process.env.GOULTRA_API_KEY,
      "Content-Type": "application/json",
      "Idempotency-Key": "order-9182-confirmed"
    },
    body: JSON.stringify({
      to: "+14155551234",
      template: "order_confirmed",
      language: "en",
      params: ["Jordan", "#9182"]
    })
  });
  const answer = await res.json();   // an error is { "error": { "code", "message", "request_id" } }
  console.log(res.status, res.headers.get("X-Request-Id"), answer);
}
main().catch((err) => { console.error(err); process.exitCode = 1; });

Python

# Python 3 - send a template (standard library only)
import json, os, urllib.error, urllib.request

req = urllib.request.Request(
    "https://api.goultra.ai/v1/messages",
    data=json.dumps({
        "to": "+14155551234",
        "template": "order_confirmed",
        "language": "en",
        "params": ["Jordan", "#9182"],
    }).encode(),
    headers={
        "Authorization": "Bearer " + os.environ["GOULTRA_API_KEY"],
        "Content-Type": "application/json",
        "Idempotency-Key": "order-9182-confirmed",
    },
    method="POST",
)
try:
    with urllib.request.urlopen(req, timeout=60) as res:
        print(res.status, res.headers["X-Request-Id"], json.load(res))
except urllib.error.HTTPError as err:  # 4xx and 5xx: the error envelope
    print(err.code, err.headers["X-Request-Id"], json.load(err))

PHP

<?php
// PHP - send a template (the curl extension)
$ch = curl_init("https://api.goultra.ai/v1/messages");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HEADER => true,
    CURLOPT_TIMEOUT => 60,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("GOULTRA_API_KEY"),
        "Content-Type: application/json",
        "Idempotency-Key: order-9182-confirmed",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "to" => "+14155551234",
        "template" => "order_confirmed",
        "language" => "en",
        "params" => ["Jordan", "#9182"],
    ]),
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$answer = json_decode(substr($raw, $headerSize), true);
echo $status, " ", json_encode($answer), PHP_EOL;

A 200 with "status": "sent" means WhatsApp accepted the message; GET /v1/messages/:id says what happened next (Messages). Anything else is the error envelope: its code is in the error catalog, with whether a retry is safe.

Receive a webhook and verify it

  1. Register an https endpoint with POST /v1/webhooks and keep the signing_secret of the answer: it is shown once (Webhooks).
  2. Verify every delivery before trusting it: X-GoUltra-Signature-256 over the raw body, with your whole secret, at most 5 minutes old (Verify the signature, in Node.js, Python, PHP and the shell).
  3. Answer 2xx within 8 seconds, then do the work, and ignore an id you already handled: a delivery can arrive twice. See Delivery and retries.
  4. Send yourself a test event with POST /v1/webhooks/:id/test (Test events); the payload of every event is in Events and payloads.
curl https://api.goultra.ai/v1/webhooks \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/goultra/webhook",
    "events": ["message.delivered", "inbound.message", "booking.created"]
  }'

A complete receiver in Node.js, with no framework (the verification function is the one in Verify the signature):

// Node.js - verify X-GoUltra-Signature-256: t=TIMESTAMP,v1=HEX (two v1 values while a rotated secret overlaps)
const crypto = require("crypto");
function verifyGoUltraV2(rawBody, header, signingSecret, toleranceSeconds = 300) {
  if (typeof header !== "string") return false;
  let t = NaN;
  const v1 = [];   // one signature, or two while the previous secret of a rotation still signs
  for (const item of header.split(",")) {
    const i = item.indexOf("=");
    if (i <= 0) continue;
    const key = item.slice(0, i).trim(), value = item.slice(i + 1).trim();
    if (key === "t") t = Number(value);
    else if (key === "v1") v1.push(value);
  }
  if (!Number.isInteger(t) || v1.length === 0) return false;
  // refuse a delivery signed too long ago, or in the future: a replay
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", signingSecret)
    .update(t + "." + rawBody, "utf8")   // rawBody: the exact body received, as a string
    .digest("hex");
  // accept the delivery when ANY v1 is the signature made with your secret
  return v1.some((sig) => sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}

// Node.js 18 or later - a receiver with no framework: verify, answer at once, then do the work
const http = require("http");
const handled = new Set();   // keep the ids you handled in your database
http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks).toString("utf8");   // the exact body received
    const secret = process.env.GOULTRA_WEBHOOK_SECRET;     // the whole whsec_ value
    if (!verifyGoUltraV2(raw, req.headers["x-goultra-signature-256"], secret)) {
      res.writeHead(400).end();
      return;
    }
    const event = JSON.parse(raw);
    res.writeHead(200).end();               // within 8 seconds
    if (handled.has(event.id)) return;      // a retry or a duplicate
    handled.add(event.id);
    console.log(event.type, JSON.stringify(event.data));
  });
}).listen(Number(process.env.PORT) || 3000);

Authentication

Authenticate every request with a bearer token:

Authorization: Bearer gk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Keys start with gk_live_ or gk_test_ and are created and revoked in the Dashboard (an older account key starting with sk_ keeps working). Treat a key like a password: it can send messages and read customer data on your account. Never expose it in a browser, a mobile app, or a public repo. If a key leaks, revoke it in the Dashboard; revocation takes effect within a minute.

Server-side only. All GoUltra keys are secret keys. Never embed a key in a browser, a mobile app, or client-side code - keep every API call on your server.

Keys and scopes

Every key is a secret key (see Authentication), and there are two kinds:

  • A number key belongs to one WhatsApp number: the key the Dashboard API area shows for the number selected in the sidebar, or a key created for one number. It acts on that number only, on every endpoint: another number of your account, named in a request or owning what a request names, is refused with 403 number_mismatch, and a request that names no number uses the number of the key (see Per-number keys). The webhook endpoints it registers receive the events of its number (see Endpoints and numbers).
  • An account-level key belongs to no number: a key created without one, or an older account key starting with sk_. It acts on every number of your account; on an account with more than one number, name the number with phone_number_id.

Contacts and opt-outs, usage and the account itself belong to the account, not to a number: every key with the scope reads them.

Least privilege

Each key carries a set of scopes that limit what it can do. A request to an endpoint whose scope the key lacks returns 403 insufficient_scope. Grant a key only the scopes your integration needs; a key created with no scope ticked gets messages:write only. A number key has every service turned on until you choose the services of that number in the API area; choosing them keeps the same key. Use one key per integration, so that one can be rotated or revoked without touching the others. For example:

  • a store plugin or a one-time-code integration: messages:write and otp:send (and plugin:woo for the WooCommerce plugin);
  • a CRM that reads and answers chats: inbox:read, inbox:write and contacts:read;
  • a booking system: bookings:read and bookings:write;
  • the one key that manages your webhook endpoints: webhooks:read and webhooks:write.

Test keys

A key labelled test (it starts with gk_test_) is read-only since v2.9.393. It reads what its scopes allow, and the one thing it can send is a test event to your own webhook endpoint (POST /v1/webhooks/:id/test, with webhooks:write). A call that would deliver a WhatsApp message to a person is refused with 403 test_key_cannot_send; any other call that would change your account (creating, changing or deleting a contact, a template or a webhook endpoint, or rotating a signing secret) is refused with 403 test_key_read_only. Before v2.9.393 only the calls that deliver a message were refused.

Since v2.9.401 the same holds in the WooCommerce and Shopify plugins: a test key is refused with 403 test_key_read_only on every plugin route that sends a message or changes something, and reads stay open, the connect check of the plugin among them, which records nothing for a test key. A store uses a live key.

Rotation

  • A number key: generate a new key for the number in the API area. The old key stops working at once, and everywhere within a minute, so move your integration to the new key right away.
  • A key created in the Dashboard: create the new key, move your integration to it, then revoke the old one; revocation takes effect within a minute.
  • A webhook signing secret: POST /v1/webhooks/:id/secret answers the new secret, once. From then on every attempt is signed with the new secret only, retries of earlier events included, so let your server accept the old and the new secret for a few minutes while you roll. Since v2.9.394 you can ask for an overlap instead: with keep_previous_for_seconds the previous secret keeps signing beside the new one for up to 7 days, and a verifier that accepts either signature (the samples in Verify the signature) passes while you move (see Rotating the secret).

Scopes

ScopeGrants
messages:writeSend template messages (POST /v1/messages) and read their status. Session replies need inbox:write; campaigns need campaigns:write.
inbox:read / inbox:writeRead conversations and messages / send free-form session replies.
contacts:read / contacts:writeList contacts / create, update, opt-out, delete contacts.
campaigns:read / campaigns:writeTrack campaign progress / submit campaigns.
bookings:read / bookings:writeList appointments / create appointments.
quotes:readList your price-list / quote menus.
templates:read / templates:writeList your message templates / create new templates and submit them for approval.
account:readRead plan, numbers and account status.
usage:readRead this month plan usage and API-sent counts, and the status of a sent message.
webhooks:read / webhooks:writeList / register and manage webhook endpoints.
otp:sendSend one-time codes.
plugin:wooUsed by the GoUltra plugin for WooCommerce stores.

Per-number keys

A GoUltra account can connect several WhatsApp numbers, and every number has its own API key, shown in the Dashboard API area for the number selected in the sidebar. GET /v1/account/numbers lists your numbers and their phone_number_id.

  • A number key acts on its own number only, on every endpoint (since v2.9.393). It sends from its number: messages, inbox replies, campaigns and bookings that name no phone_number_id use it. It reads its number: the conversations, appointments and quotes of your other numbers are not listed for it, and GET /v1/account/numbers returns its number alone. It creates templates on its number, and the webhook endpoints it registers belong to its number (Endpoints and numbers). Rows that belong to no number (made before numbers were tracked) stay visible to it.
  • Another number of your account is refused with 403 number_mismatch: a phone_number_id of another of your connected numbers, in the query or the body, or a booking, campaign or webhook endpoint that belongs to another number. The error carries the number of the key in phone_number_id. Before v2.9.393 a phone_number_id on messages and inbox replies was ignored and the message went from the number of the key; a phone_number_id that is not a number of your account is still ignored there.
  • An account with one number sees no difference: it has no other number to name.
  • The same for every key: contacts and opt-outs, usage, the account, and the status of a message by its id (GET /v1/messages/:id).
  • The template list (since v2.9.397): on an account with more than one number, a number key lists the templates of the WhatsApp Business Account of its own number; any other key lists those of your primary number, as before, or of the number it names in phone_number_id (Templates). An account with one number sees the same list as before.
  • An account-level key on an account with more than one number must name the number to send: POST /v1/messages answers 400 phone_number_id_required without it, and POST /v1/campaigns answers 400 phone_number_required. Inbox replies, bookings and quotes without it use your primary number.

Contacts and opt-out are account-level and shared across all your numbers.

Errors

Every error uses the same envelope with a machine-readable code, a human message, and a request_id to quote in support. Some errors add fields, named below and in each endpoint section.

{
  "error": {
    "code": "insufficient_scope",
    "message": "This API key lacks the messages:write scope.",
    "request_id": "req_abc123",
    "docs_path": "https://api.goultra.ai/v1"
  }
}
HTTPMeaning
400A missing or invalid field: missing_field, invalid_json, invalid_phone and the codes each endpoint section names (for example header_media_required, invalid_header_url, param_count_mismatch, button_value_required, invalid_button_payload, button_not_quick_reply, button_example_required, header_sample_failed, window_closed; for webhooks since v2.9.394, invalid_limit, invalid_status, invalid_event_type, invalid_since and invalid_keep_previous). Since v2.9.397: field_too_long, a text over its limit (field names it, max_length says how long it may be and length how long it is, left out past 32,768 characters; each endpoint section gives its limits), invalid_path, a part of the path that is not valid percent-encoding (encode each part with encodeURIComponent; it was a 500), and invalid_page, a page past the last one a list serves (Bookings: at most 1,000; narrow the list with its filters instead). invalid_phone says what is wrong with the number.
401Missing or invalid API key (unauthorized).
402Sending is blocked: account_inactive (billing), quota_exceeded (the plan limit; scope says daily or monthly), contact_limit_exceeded, or meta_tier_limit (the number reached its WhatsApp 24-hour recipient limit, which frees up on a rolling 24 hours, not with an upgrade). Campaigns add their own, listed under Campaigns.
403insufficient_scope, test_key_cannot_send, test_key_read_only (a test key cannot change your account: Test keys), number_mismatch (a number key named or reached another number of your account; phone_number_id in the error is the number of the key: Per-number keys), opted_out, optin_recipient_only, access_blocked or, since v2.9.397, marketing_paused (marketing is paused for your account and the template is a MARKETING one: see Messages).
404not_found: no such resource on your account, or no such endpoint.
409time_conflict: a booking time is already taken. delivery_in_progress: the webhook delivery is still queued or being sent, so it is not sent again; endpoint_disabled: the webhook endpoint is disabled (the error carries disabled_reason). See Delivery log and redelivery. idempotency_in_progress: a request with the same Idempotency-Key is still running; retry after Retry-After (2 seconds) to get its result. Only once the stricter check in Idempotency is on.
413payload_too_large: the body is larger than the endpoint takes (max_bytes, in bytes; see Rate limits). Nothing was read or done (since v2.9.402).
422idempotency_key_reused: the Idempotency-Key was used before with a different body, so nothing was done; use a new key for a new action. Only once the stricter check in Idempotency is on.
429rate_limit or api_suspended: see Rate limits; test_rate_limited: see Test events; redeliver_rate_limited: see Delivery log and redelivery. Every 429 carries a Retry-After header: the whole seconds to wait, the same number as retry_after in the error (since v2.9.395, and redeliver_rate_limited since v2.9.394; no field of the error was renamed or removed).
500server_error (on some endpoints campaign_failed, booking_failed or query_failed): an unexpected error. After a read it is safe to retry. After a create, the record may have been stored before the error (a campaign, the calendar event of a booking, a webhook endpoint): list or GET first, then send it again. An Idempotency-Key covers only a request that succeeded.
502WhatsApp refused the message (send_failed). The error carries meta_error_code, meta_error_details, fbtrace_id and, for a template mismatch, a hint. With meta_error_code timeout or network, GoUltra did not get the answer of WhatsApp, and the message may have been delivered: do not send it again automatically. GoUltra has no message id for it, so no message.* webhook follows, and only the recipient can tell. An Idempotency-Key covers only a request that succeeded.
503Temporarily unavailable: opt_out_check_unavailable (retry shortly; retryable is true), number_check_unavailable (the numbers of your account could not be checked for a number key, so nothing was done; retry shortly, retryable is true), redeliver_unavailable (a bulk redelivery could not set aside the time to send its deliveries, so nothing was sent again; retryable is true and retry_after says when: see Delivery log and redelivery) or campaign_partially_queued (part of a campaign was queued: do not submit it again). Of these, only redeliver_unavailable carries a Retry-After header, the same seconds as its retry_after (since v2.9.395): nothing was done, so a retry then is safe. None of the others carries one, campaign_partially_queued least of all: do not submit that campaign again. During planned maintenance every endpoint answers 503 with "error": "maintenance", the one answer outside this envelope, and a Retry-After header in seconds.

The error catalog

Every code the API can answer, with its HTTP status (the usual one first), whether the same request can be sent again, and what to do. GET https://api.goultra.ai/v1/errors serves the same list as JSON (no key needed), and the OpenAPI document lists the codes of each operation in x-error-codes. The list is checked against the code: a code the API can answer is in it, and it names no code the API never answers.

Retry: No means the same request gets the same answer; Yes means nothing was done, so the same request may succeed shortly; After the wait means after the seconds of Retry-After (and error.retry_after). Send a retry with the same Idempotency-Key where the endpoint takes one: a request that succeeded is answered again, never sent twice. The key covers only a request that succeeded: a request that failed runs again. So after a 502 send_failed with meta_error_code timeout or network (WhatsApp may have delivered the message), and after a 500 that can follow a create (server_error, booking_failed, campaign_failed), check first: after such a send, with the recipient (GoUltra has no message id for it, so no message.* webhook follows); after a create, with a list or a GET.

CodeHTTPRetryMeaning, and what to do
access_blocked403No: change the request firstAccess to GoUltra is suspended for this account. What to do: Contact support from your GoUltra Dashboard.
account_inactive402No: change the request firstThe subscription of the account is inactive (canceled or payment overdue), so nothing is sent. What to do: Reactivate the plan in the Dashboard, then send again.
account_paused402No: change the request firstOutbound sending is paused on the account while it is under review. What to do: Contact support to resume.
api_suspended429After the wait in Retry-After. After the seconds in Retry-After and error.retry_after (at most 3600). The suspension can last longer, so a request then can get it again.API access of the account is suspended after unusually high volume: more than 3,000 accepted requests in an hour, or more than 30,000 refused ones. What to do: Slow down. Contact support if the volume is legitimate.
bad_request400No: change the request firstThe campaign or booking was refused without a more precise code; the message says why (for example a missing field, or more than 100,000 recipients). What to do: Change the request as the message says.
booking_failed500After a create, list or GET first; the key does not cover a failed request.The appointment could not be stored. Its Google Calendar event may already have been created. What to do: List the appointments (GET /v1/bookings) before you send it again.
button_example_required400No: change the request firstA URL button whose link ends in {{1}} needs an example (error.button_index names it). What to do: Add example to that button.
button_not_dynamic400No: change the request firstA value was given for a button whose link has no {{1}}. What to do: Remove the value, or use a template whose link ends in {{1}}.
button_not_quick_reply400No: change the request firstA payload was given for a button that is not QUICK_REPLY. What to do: Give a payload only to a QUICK_REPLY button (GET /v1/templates lists the type of each button).
button_value_required400No: change the request firstA link button ending in {{1}} got no value, or an entry of buttons has neither value nor payload. What to do: Send the part of the link that replaces {{1}} as value.
campaign_failed500After a create, list or GET first; the key does not cover a failed request. Within 120 seconds the same template to the same recipients answers the first campaign (duplicate: true); after that a retry starts a new campaign.The campaign could not be submitted. It may have been created before the failure. What to do: Send it again within 120 seconds: the same template to the same recipients then answers the campaign the failed request created (duplicate: true) instead of sending again. Later, check the Dashboard first.
campaign_partially_queued503No: change the request firstPart of the campaign was queued and will be sent; the rest was not. The message says how many were queued. What to do: Do not submit it again: the queued part would be sent twice. Contact support with the request_id.
campaign_prepare_failed503Yes, shortly: nothing was doneThe recipients could not all be loaded, so the campaign was not started and nothing was sent. What to do: Submit it again.
contact_limit_exceeded402No: change the request firstThe contact limit of the plan is reached; error.current and error.max say where the account stands. What to do: Delete contacts you no longer need, or upgrade the plan.
delivery_in_progress409Once GET /v1/webhooks/:id/deliveries/:delivery_id shows it delivered, dlq or cancelled.The webhook delivery is still queued or being sent (error.status). What to do: Wait until it has arrived or failed, then send it again.
endpoint_disabled409No: change the request firstThe webhook endpoint is disabled; error.disabled_reason is "failing" when GoUltra turned it off. What to do: Turn it on with PATCH /v1/webhooks/:id {"status": "active"}, then send the deliveries again.
field_too_long400No: change the request firstA text is longer than its limit: error.field, error.max_length and error.length say which (error.param or error.key names the entry of a list). What to do: Shorten it.
first_large_campaign_review400Yes, with "first_large_review_ack": true.A first large campaign (more than 500 recipients, or more than half the daily limit, while the account has fewer than two completed campaigns) is held once for review. What to do: Confirm the list is opted in, then submit it again with "first_large_review_ack": true.
header_media_required400No: change the request firstThe template has an image, video or document header and no header_url was given (error.header_type). What to do: Send header_url with the media.
header_not_supported400No: change the request firstThe header cannot be used through the API: a location header, a text header with a variable, or a variable in header_text. What to do: Use a template without that header, or remove the variable.
header_sample_failed502, 400A 502 can be retried; a 400 needs another header_url.The sample of a media header could not be uploaded to Meta. What to do: With your own header_url (a 400), change the link; otherwise retry.
header_type_mismatch400No: change the request firstheader_url is not the kind of media the header of the template takes (error.header_type). What to do: Send media of the kind the header takes.
idempotency_in_progress409After the wait in Retry-After. After the 2 seconds of Retry-After; nothing is done twice.A request with this Idempotency-Key is still running. Answered once the stricter idempotency check is on (see Idempotency); until then the second request runs as before. What to do: Wait, then retry with the same key to get the first result.
idempotency_key_reused422No: change the request firstThe Idempotency-Key was used before for a request with a different body; the first result stands and nothing new was done. Answered once the stricter idempotency check is on. What to do: Use a new key for a new action.
insufficient_scope403No: change the request firstThe API key lacks the scope of this endpoint. What to do: Use a key that has the scope (see Keys and scopes).
invalid_allow_category_change400No: change the request firstallow_category_change is not true or false. What to do: Send a boolean, or leave it out.
invalid_button400No: change the request firstAn entry of buttons cannot be used: not a list, an index that is not a whole number from 0 to 9 or repeats, an unknown key, or a button text, type, phone number or link that is missing or invalid. What to do: Fix the entry the message names.
invalid_button_example400No: change the request firstThe example of a URL button does not fit its link (error.button_index). What to do: Send the full sample link, or only the part that replaces {{1}}.
invalid_button_payload400No: change the request firstA quick-reply payload is not 1 to 128 printable ASCII characters, or is a reserved value (error.button_index). What to do: Send another payload.
invalid_button_value400No: change the request firstA link button value is empty, too long, holds a space, or repeats the start of the link (error.button_index). What to do: Send only the part of the link that replaces {{1}}.
invalid_category400No: change the request firstcategory is not UTILITY or MARKETING. What to do: Send UTILITY or MARKETING.
invalid_cursor400No: change the request firstbefore is not a value an earlier page gave in next_before. What to do: Send next_before exactly as the previous page gave it.
invalid_duration400No: change the request firstduration is not a number of minutes from 1 to 1,440 (error.field, error.max). What to do: Send the length of the appointment in minutes, or leave it out for 30.
invalid_event_type400No: change the request firstevent_type is not the name of a webhook event. What to do: Use a name from available_events of GET /v1/webhooks.
invalid_examples400No: change the request firstexamples is not a list with one sample for each body variable. What to do: Send one sample per {{n}}.
invalid_field400No: change the request firstA field has a value that cannot be used (for example a time that cannot be read). What to do: Fix the field the message names.
invalid_footer400No: change the request firstThe footer is longer than 60 characters. What to do: Shorten it.
invalid_header_text400No: change the request firstheader_text is given for a header that is not TEXT, or is longer than 60 characters. What to do: Fix header_type or header_text.
invalid_header_type400No: change the request firstheader_type is not one of the values the endpoint takes. What to do: Send one of the documented values.
invalid_header_url400No: change the request firstheader_url is not a valid link for this request, or is given for a header that takes none. What to do: Send a public https link for a media header.
invalid_json400No: change the request firstThe body is not valid JSON, or not a JSON object where one is required. What to do: Send a JSON object with Content-Type: application/json.
invalid_keep_previous400No: change the request firstkeep_previous_for_seconds is not a whole number from 0 to 604800; nothing was rotated. What to do: Send a whole number of seconds up to 7 days, or leave it out.
invalid_limit400No: change the request firstlimit is outside the range the endpoint takes. What to do: Send a limit in the documented range.
invalid_name400No: change the request firstThe template name has characters other than lowercase letters, digits and underscores. What to do: Use lowercase_underscore.
invalid_page400No: change the request firstpage is above the highest page the list serves. What to do: Narrow the list with its date filters instead of paging further.
invalid_path400No: change the request firstA part of the path is not valid percent-encoding (a % must be followed by two hex digits, and the bytes must be UTF-8). What to do: Encode each path segment with encodeURIComponent.
invalid_phone400No: change the request firstA phone number cannot be read as a number with its country code; the message says why. What to do: Send the number with its country code, for example +14155551234.
invalid_phone_number_id400No: change the request firstphone_number_id is not a connected WhatsApp number of the account. What to do: Use a phone_number_id from GET /v1/account/numbers.
invalid_since400No: change the request firstsince is not a time in Unix seconds within the last 7 days. What to do: Send a whole number of seconds, at most 7 days ago.
invalid_status400No: change the request firststatus is not one of the values the endpoint takes. What to do: Send one of the documented values.
invalid_test_delay400No: change the request firstdelay_seconds is not a whole number from 0 to 900. What to do: Send 0 to 900, or leave it out.
invalid_test_resend400No: change the request firstresend does not name a test event of this endpoint that can be sent again, or is sent with type or sample. What to do: Name a test delivery that arrived or failed, alone.
invalid_test_sample400No: change the request firstsample is not button or text. What to do: Send button or text, or leave it out.
invalid_url400No: change the request firstThe webhook url is not an https URL. What to do: Register an https URL.
limit_exceeded402No: change the request firstThe message limit of the plan is reached. What to do: Wait for the limit to reset, or upgrade the plan.
marketing_disabled403No: change the request firstMarketing campaigns are paused for the account, and the template of the campaign is MARKETING at Meta (error.template_category; since v2.9.404 the category at Meta decides, not the one sent). What to do: Send a template whose category at Meta is UTILITY, or wait until marketing is resumed.
marketing_paused403No: change the request firstThe template is MARKETING and marketing is paused for the account (error.template_category). What to do: Send a UTILITY template, or wait until marketing is resumed.
member_monthly_cap403No: change the request firstThe team member named by team_member_id has reached a monthly cap. What to do: Wait for the next month, or raise the cap of the member in the Dashboard.
member_not_found403No: change the request firstteam_member_id is not a member of the account. What to do: Send the id of a member, or leave it out.
member_suspended403No: change the request firstThe team member named by team_member_id is suspended. What to do: Name another member, or leave it out.
meta_tier_limit402Later, as the 24-hour window moves; no wait is named.The WhatsApp messaging tier of the number is used up for the last 24 hours (error.tier, error.tier_limit, error.headroom). What to do: Send later, or from another number.
meta_tier_limit_exceeded402Later, as the 24-hour window moves; no wait is named.The campaign would go past the WhatsApp messaging tier of the number for the last 24 hours. What to do: Send fewer recipients, later, or from another number.
missing_field400No: change the request firstA required field is missing; the message names it. What to do: Send the field.
name_exists400No: change the request firstA template with this name already exists. What to do: Use another name.
not_configured400No: change the request firstWhatsApp is not set up for the number the request would use, or the number of a number key cannot send. What to do: Connect the number in the Dashboard, or name another phone_number_id.
not_found404No: change the request firstNo such resource on your account, or no such endpoint. What to do: Check the id and the path.
nothing_to_update400No: change the request firstThe body changes nothing. What to do: Send at least one field to change.
number_check_unavailable503Yes, shortly: nothing was doneWhich WhatsApp number the request is for could not be checked just now, so nothing was done (error.retryable is true). What to do: Retry shortly.
number_mismatch403No: change the request firstThe API key belongs to another WhatsApp number of the account; error.phone_number_id names the number of the key. What to do: Use the key of that number, or an account-level key.
opt_out_check_unavailable503Yes, shortly: nothing was doneWhether the recipient opted out could not be checked just now, so nothing was sent (error.retryable is true). What to do: Retry shortly.
opted_out403No: change the request firstThe recipient opted out. Only the recipient can opt back in, by messaging you. What to do: Do not send to this number.
optin_recipient_only403No: change the request firstOnly the recipient can opt back in. What to do: Ask the customer to message you.
param_count_mismatch400No: change the request firstparams has another count than the body variables of the template (error.param_count says how many it takes). What to do: Send one value per {{n}}, in order.
payload_too_large413No: change the request firstThe body of the request is larger than the endpoint takes (error.max_bytes, in bytes): nothing was read or done. What to do: Send a smaller body: fewer entries, or shorter texts. Each endpoint section of the docs gives its limit.
permission_check_failed403Yes, shortly: nothing was doneThe permissions of the team member could not be checked just now. What to do: Retry shortly.
permission_denied403No: change the request firstThe team member named by team_member_id may not send campaigns. What to do: Name another member, or leave it out.
phone_number_id_required400No: change the request firstThe account has more than one WhatsApp number and the request names none. What to do: Name the sending number in phone_number_id, or use the key of that number.
phone_number_required400No: change the request firstThe account has more than one WhatsApp number and the campaign names none. What to do: Name the sending number in phone_number_id, or use the key of that number.
phone_quality_red402No: change the request firstThe quality rating of the number is red, so campaigns are paused on it. What to do: Read the message; send from another number.
phone_quality_unverified402Later, once the rating is confirmed; no wait is named.The quality rating of the number could not be confirmed just now. What to do: Retry later.
portfolio_unresolved402No: change the request firstThe number is not ready for campaigns at Meta (its setup is unfinished or could not be resolved). What to do: Read the message: finish the setup in Meta Business Manager.
query_failed500Yes, shortly: nothing was doneThe list could not be read just now. What to do: Retry shortly.
quota_exceeded402When the daily or monthly quota resets, or after an upgrade.The quota of the plan is used up (error.scope is daily or monthly, with error.limit and error.current); on POST /v1/inbox/send also a paused or inactive account or the sending burst limit. What to do: Wait for the reset, or upgrade the plan.
rate_limit429After the wait in Retry-After. After the seconds of Retry-After and error.retry_after.Too many requests: the 600 a minute of the key, the sending burst limit, or the pace of the number. What to do: Slow down.
recipients_over_member_cap403No: change the request firstThe campaign has more recipients than the team member may send to. What to do: Send fewer recipients, or name another member.
redeliver_rate_limited429After the wait in Retry-After. After the seconds of Retry-After and error.retry_after.Redelivery is limited for now: 100 single redeliveries an hour per endpoint, one bulk redelivery every 5 minutes, one bulk of an account at a time. What to do: Wait, then send it again.
redeliver_unavailable503After the wait in Retry-After. After the seconds of Retry-After and error.retry_after.The deliveries could not be scheduled now, so nothing was sent again. What to do: Wait, then send them again.
send_blocked402No: change the request firstSending is blocked for the account. What to do: Read the message; see the Dashboard.
send_failed502, 500A refusal with the code of WhatsApp is sent again only after the request is fixed. A timeout or network failure is NOT resent automatically: the message may have arrived, and GoUltra cannot tell (it has no message id for it, so no message.* webhook follows); only the recipient can. The Idempotency-Key covers only a request that succeeded: a request that failed runs again.WhatsApp refused the message (502, with meta_error_code, meta_error_details, fbtrace_id and sometimes hint), OR GoUltra did not get the answer of WhatsApp (502 with meta_error_code timeout or network): then it may have been delivered. On POST /v1/inbox/send, a 500 means the reply could not be sent. What to do: A refusal with the code of WhatsApp: read meta_error_code and hint, and fix the template, the params or the media first. A timeout or network failure: do not send it again automatically. GoUltra has no message id for it, so no message.* webhook follows: ask the recipient first.
server_error500, 502After a create, list or GET first; the key does not cover a failed request.An unexpected error on our side. It can come after a create was stored (a webhook endpoint registered, for example), so the same request sent again can make a second one. What to do: Retry a read. Before you send a create again, list or GET to see whether it was made.
template_create_failed400, 403, 500A 500 can be retried; any other status needs the template changed.The template was refused; the message says why, with meta_error_code and meta_error_title when Meta gave them. What to do: Read the message and change the template.
template_limit_exceeded402No: change the request firstThe template limit of the plan is reached. What to do: Delete templates you no longer use, or upgrade the plan.
template_not_sendable402No: change the request firstThe template cannot be sent now: not approved at Meta, or paused there. What to do: Check its status in GET /v1/templates?status=ALL.
template_quality_blocked402After the hold the message names.The template is held after a quality problem at Meta; the message says for how long. What to do: Use another template, or wait.
tenant_route_unavailable402Yes, shortly: nothing was doneThe data of the account could not be reached just now, so nothing was sent. What to do: Retry shortly.
test_key_cannot_send403No: change the request firstA test key cannot deliver a WhatsApp message to a real person. What to do: Use a live key to send.
test_key_read_only403No: change the request firstA test key cannot create, change or delete anything; it reads and sends test events to webhook endpoints. What to do: Use a live key for this call.
test_rate_limited429After the wait in Retry-After. After the seconds of Retry-After and error.retry_after.More than 20 test events in an hour for this key or this endpoint. What to do: Wait for the next hour.
time_conflict409No: change the request firstThe time of the appointment is already taken. What to do: Pick another time.
too_many_buttons400No: change the request firstMore than 3 buttons. What to do: Send at most 3.
too_many_endpoints400No: change the request firstThe account already has 10 active webhook endpoints. What to do: Disable or delete one first.
unauthorized401No: change the request firstThe Authorization header is missing, or the API key is empty or not valid. What to do: Send Authorization: Bearer <API_KEY> with a valid key.
whatsapp_not_configured400No: change the request firstWhatsApp is not set up for the number (no WhatsApp Business Account or access token). What to do: Connect the number in the Dashboard.
window_closed400No: change the request firstThe 24-hour window of the conversation is closed: the customer last wrote more than 24 hours ago. What to do: Send an approved template with POST /v1/messages.

Rate limits

  • Per key: 600 requests a minute. Above it the answer is 429 rate_limit with retry_after: the seconds left in that minute. A refused request is not counted, so calling again too early never stretches the wait.
  • Per account: 3,000 requests an hour, counting every request of every key (test keys included), one-time codes and store plugins, except a request refused by the per-key limit or by a test-key rule (not counted since v2.9.395). Above it the API of the whole account is suspended for up to 6 hours (429 api_suspended) and we are alerted; contact support to lift it sooner. Its retry_after says when to check again: the seconds left in the suspension, but never more than 3600, because a suspension can be lifted early. It may last longer than that, so the next answer can be the same 429 with a new wait. Requests refused by the per-key limit or by a test-key rule count instead toward a separate 30,000 an hour (since v2.9.395), and above it the account is suspended the same way. Polling once a second passes this limit within the hour, so receive events through webhooks rather than polling. Since v2.9.402 the hour counts every accepted request, whatever the rate and the number of keys, verifications included.
  • Request size. Since v2.9.402 a body larger than its endpoint takes is refused before anything is read or done, with 413 payload_too_large and max_bytes: 32 MB for POST /v1/campaigns, 1 MB for POST /v1/messages and POST /v1/templates, 128 KB for POST /v1/inbox/send, and 64 KB for any other endpoint that takes a body. Except for campaigns, each leaves room for every text of its endpoint at its limit, written in the longest JSON form; the 32 MB of a campaign is about 335 bytes for each of 100,000 recipients: when your recipients carry more (a long name, contact fields), send fewer of them in each call (10,000 a call leave about 3,350 bytes each). A body sent without a Content-Length is read up to the limit and no further.
  • Sending. Each send also passes your plan quota (402 quota_exceeded), a short-term sending burst limit (429 rate_limit with retry_after: the seconds until its 5-minute window ends), your number throughput (429 rate_limit with retry_after_ms, and retry_after in whole seconds) and its WhatsApp tier (402 meta_tier_limit).

Reading where you stand

Since v2.9.395 every answer to a request counted in the per-key minute carries three headers, whatever its status (a 429 rate_limit included):

  • X-RateLimit-Limit: the requests a key may make in a minute (600).
  • X-RateLimit-Remaining: how many are left in the current minute.
  • X-RateLimit-Reset: the whole seconds until the current minute ends and the count starts again.
HTTP/1.1 200 OK
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 597
X-RateLimit-Reset: 42

Every 429 carries a Retry-After header, the same seconds as retry_after in the error, and so does 503 redeliver_unavailable, the one 503 error that names its wait (nothing was done, so a retry is safe); the 503 of planned maintenance carries its own. An answer that stops before the per-key limit has no X-RateLimit headers: a missing or unknown key, a blocked account, a suspended account (its 429 still has Retry-After). Nor does an answer whose count could not be read: the request is then let through, as always. A browser may read all of these headers.

Idempotency

POST /v1/messages, POST /v1/campaigns, POST /v1/bookings and POST /v1/inbox/send take an Idempotency-Key header. Retry with the same key and GoUltra returns the original result instead of acting twice, so a retry after a timeout never sends two messages or creates two records once the first request succeeded. A request that failed is not covered: it runs again. After a 502 send_failed with meta_error_code timeout or network the message may have been delivered all the same, and GoUltra has no message id for it (no message.* webhook follows): do not send it again automatically (the error catalog says, for each code, whether to send again).

  • One key, one action. Use a value unique to the action, such as order-9182-confirmed, not only the order id. A key used again for a different message returns the first result, and nothing new is sent (see below for what changes once the stricter check is on).
  • How long. For messages, campaigns and bookings a successful result is kept for 24 hours. A failed request is not kept, so a retry with the same key tries again. An inbox reply keeps its key with the message: a retry answers that message with status sent and its WhatsApp id (null until it has gone out).
  • A replay says so. Since v2.9.395 an answer that repeats an earlier result carries the header Idempotent-Replayed: true; its body is the first result, as before.
  • Held from the first moment. Since v2.9.395 messages, campaigns and bookings hold the key from the moment the first request starts, not only once it has finished, and keep a fingerprint of its body (the same fields with the same values, in any order, are the same body). So a second request with the key while the first is still running is recognised, and so is a key used again with a different body. A request that fails lets the key go, so a retry with it runs again; one that never finished frees it after 2 minutes.
  • What they get, now and later. For now both are answered exactly as before: the second request runs, and a different body gets the first result; we count them. Once the stricter check is switched on (this section and the Changelog will say from when), the second request while the first runs is refused with 409 idempotency_in_progress and Retry-After: 2 (retry then to get its result), and a different body with 422 idempotency_key_reused (nothing is done: use a new key for a new action). An inbox reply keeps its own rule: two replies with one key at the same moment both answer the reply that was queued.
  • When to retry. Wait for the response of the first request, or for a client timeout of at least 60 seconds, and never send one key twice at the same moment. When WhatsApp answers slowly, POST /v1/messages can take about 25 seconds to answer (up to 20 for the send itself and up to 5 to read the template), plus our database reads, so a client that gives up sooner retries a message that may still be going out.
  • Not covered. POST /v1/templates (a retry after Meta accepted answers 400 name_exists), POST /v1/webhooks (a retry adds a second endpoint), POST /v1/webhooks/:id/secret (a retry rotates the secret again) and /test ignore the header: after a timeout, check with a GET before retrying. POST /v1/contacts updates an existing phone, so a retry is harmless.
-H "Idempotency-Key: order-9182-confirmed"

Pagination

The lists page in one of three ways, and each answer says how to ask for the next page. A page is never larger than its limit; read until the answer says there is no more.

  • Offset (GET /v1/contacts, GET /v1/inbox/conversations): limit and offset; while has_more is true, ask again with offset = the previous offset + limit.
  • Cursor (GET /v1/inbox/messages, GET /v1/webhooks/:id/deliveries): pass before = next_before of the previous page, exactly as it came; next_before is null when there is nothing older. A cursor page can be shorter than its limit, even empty, while next_before is set: keep going until it is null. A before that is not such a value is 400 invalid_cursor.
  • Page (GET /v1/bookings): page (from 1) and limit; pagination gives total and pages, so ask for page + 1 while it is below pages. Pages stop at 1,000: narrow a longer list with date_from and date_to.
RoutePages byThe next page
GET /v1/quotesone answerOne answer holds the whole list.
GET /v1/contactslimit and offsetPass offset = the previous offset + limit while has_more is true.
GET /v1/templatesone answerOne answer holds the whole list.
GET /v1/bookingspage and limitPass page = page + 1 while page is below pagination.pages.
GET /v1/inbox/conversationslimit and offsetPass offset = the previous offset + limit while has_more is true.
GET /v1/inbox/messagesbefore (a cursor)Pass before = next_before of the previous page; next_before is null when there is nothing older.
GET /v1/account/numbersone answerOne answer holds the whole list.
GET /v1/webhooksone answerOne answer holds the whole list.
GET /v1/webhooks/:id/deliveriesbefore (a cursor)Pass before = next_before of the previous page; next_before is null when there is nothing older.

A list route not in the table answers its whole list at once.

Webhooks

Register HTTPS endpoints to receive events: a message delivered, a customer messaged you or tapped a button, a booking created. The first delivery attempt is made as soon as the event is created; deliveries are signed and retried with backoff.

Manage endpoints

POST/v1/webhooksscope: webhooks:write

Register an endpoint. Body: url (https only; anything else is 400 invalid_url), optional events, description. Unknown event names are dropped, and when none is valid (or events is left out) the endpoint receives every event: check events in the answer. Since v2.9.397, when events is given, the answer lists the names it dropped in ignored_events, and warnings says so when none was valid. Up to 10 active endpoints (400 too_many_endpoints). Returns the signing secret ONCE: store it to verify signatures. Takes no Idempotency-Key: after a timeout, check GET /v1/webhooks before retrying, or every event arrives twice. Registered with a number key, the endpoint belongs to that number (Endpoints and numbers).

curl https://api.goultra.ai/v1/webhooks \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/goultra/webhook",
    "events": ["message.delivered", "inbound.message", "booking.created"]
  }'
GET/v1/webhooksscope: webhooks:read

List your endpoints and the available event types. Since v2.9.394 a list gives disabled_reason for at most 25 turned-off endpoints, the most recently registered first, and null for any further one; GET /v1/webhooks/:id always gives it.

GET/v1/webhooks/:idscope: webhooks:read

Get one endpoint. Since v2.9.394 the endpoint object carries disabled_reason: "failing" when GoUltra turned the endpoint off because it kept failing (see When an endpoint keeps failing), otherwise null.

PATCH/v1/webhooks/:idscope: webhooks:write

Update the url, events, description, or status (active / disabled). A body that changes nothing is 400 nothing_to_update. Event names are read as on registration, and since v2.9.397 the answer carries ignored_events (and warnings) the same way when events is given. Setting status to disabled cancels the deliveries queued for the endpoint; the answer says how many in cancelled_deliveries. Setting it to active turns the endpoint back on, one GoUltra turned off included (since v2.9.405, 400 too_many_endpoints when 10 are already active: the endpoint stays as it was). Then the deliveries that failed or were cancelled can be sent again (Delivery log and redelivery); the events that happened while it was disabled were never queued for it and cannot be sent.

DELETE/v1/webhooks/:idscope: webhooks:write

Delete an endpoint. The deliveries queued for it are cancelled; the answer says how many in cancelled_deliveries.

POST/v1/webhooks/:id/secretscope: webhooks:write

Regenerate the signing secret (returned once). A retry rotates it again, and the secret returned first stops working (with an overlap, it becomes the previous secret). Optional body since v2.9.394: { "keep_previous_for_seconds": 3600 } keeps the previous secret signing beside the new one for that long, up to 604800 (7 days); see Rotating the secret.

POST/v1/webhooks/:id/testscope: webhooks:write

Send a test event to the endpoint: webhook.test without a body, or a sample inbound.message. See Test events.

Endpoints and numbers

An endpoint belongs to one number or to the account, set once when it is registered (since v2.9.393). The endpoint object says which in phone_number_id: the number, or null for the account.

  • Registered with a number key: it belongs to that number. It receives the events of that number (inbound.message, message.*, booking.created, campaign.completed) and the account events (contact.created); an event of another number never reaches it, and an event that names no number goes to account-level endpoints only.
  • Registered with an account-level key or in the Dashboard: account-level. It receives the events of every number.
  • Registered before v2.9.393, with any key: account-level, and it keeps receiving the events of every number, exactly as before.

A number key lists, reads, changes, deletes, rotates and tests the endpoints of its own number and the endpoints registered before v2.9.393; any other endpoint answers 403 number_mismatch, and GET /v1/webhooks leaves it out. The delivery log and redelivery (since v2.9.394) are narrower: a number key reads and sends again only the deliveries of the endpoints of its own number, because an account-level endpoint, one registered before v2.9.393 included, receives the events of every number. An account-level key manages every endpoint of the account. To receive one number, register the endpoint with the key of that number; to receive every number, use an account-level key. Endpoint ids are opaque: do not build or parse them.

Event types

EventFires when
message.deliveredA message sent with POST /v1/messages was delivered.
message.readA message sent with POST /v1/messages was read.
message.failedA message sent with POST /v1/messages failed after WhatsApp accepted it.
inbound.messageA customer sent you a message, or tapped a button on one of yours (see A tap on a button).
booking.createdAn appointment was created.
contact.createdA contact was created via the API.
campaign.completedA campaign finished sending. Delivery and read receipts keep arriving afterwards - poll the campaign for those.
webhook.testYou triggered a test delivery.

Inbox replies and campaign messages fire no message.* events: follow them in GET /v1/inbox/messages and GET /v1/campaigns/:id.

Verification events are delivered separately, by the verification service itself: set a webhook URL on the service and it will send sent, delivered, read, approved, failed, expired, canceled and resent for its own verifications. Those deliveries are signed with the webhook secret of the service and carry the same signature headers as the events here: X-GoUltra-Signature, and since v2.9.393 X-GoUltra-Timestamp and X-GoUltra-Signature-256. A delivery is never sent without a signing secret.

Payload

Every delivery is a POST with this JSON body plus the headers X-GoUltra-Event, X-GoUltra-Delivery (the same value as id), X-GoUltra-Signature, X-GoUltra-Timestamp and X-GoUltra-Signature-256 (see Verify the signature). data depends on the event:

{
  "id": "pwh_1782940000_a1b2c3d4",
  "type": "message.delivered",
  "created": 1782940000,
  "data": { "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBI...", "to": "14155551234", "status": "delivered", "timestamp": 1782940000 }
}
Eventdata
message.*message_id (the id POST /v1/messages returned), to, status, timestamp, and on message.failed error (the message WhatsApp gave) and, since v2.9.397, error_code and error_title (its code and title, the ones GET /v1/messages/:id gives in error).
inbound.messagephone, phone_number_id, message_type, text, media_url (inside the GoUltra app), wa_message_id, contact_name, received_at, since v2.9.392 wa_timestamp, context, button and is_button (see A tap on a button), and since v2.9.397 media: for a message with a file, { "url", "mime_type" }, where url downloads the file with your API key (GET /v1/media/:key) and mime_type is its type; otherwise null.
booking.createdid, source (whatsapp, api or dashboard), start_time, end_time, title, phone, resource_id, phone_number_id.
contact.createdid, phone, name, email.
campaign.completedcampaign_id, name, template_name, language, category, phone_number_id, status, total_recipients, sent and the other counts.
webhook.testmessage, endpoint_id.

Events and payloads

Every delivery is a POST of the envelope { "id", "type", "created", "data" } (a sample inbound.message sent with POST /v1/webhooks/:id/test adds "test": true). Below, the data of each event and a full example; the code produced each example. The OpenAPI document has the same schemas under webhooks. New fields are added at the end of data: ignore a field you do not know.

message.delivered

A message sent with POST /v1/messages was delivered.

data fieldTypeWhat it is
message_idstringThe id POST /v1/messages returned.
tostring or null
statusstring
timestampintegerWhen GoUltra processed the receipt.
errorstring or nullmessage.failed: the message WhatsApp gave.
error_codeinteger or nullmessage.failed: the WhatsApp error code.
error_titlestring or nullmessage.failed: the WhatsApp error title.
Example message.delivered
{
  "id": "pwh_1792800434_k2m9x4qa",
  "type": "message.delivered",
  "created": 1792800434,
  "data": {
    "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBIzMw==",
    "to": "14155551234",
    "status": "delivered",
    "timestamp": 1792800000
  }
}

message.read

A message sent with POST /v1/messages was read.

data fieldTypeWhat it is
message_idstringThe id POST /v1/messages returned.
tostring or null
statusstring
timestampintegerWhen GoUltra processed the receipt.
errorstring or nullmessage.failed: the message WhatsApp gave.
error_codeinteger or nullmessage.failed: the WhatsApp error code.
error_titlestring or nullmessage.failed: the WhatsApp error title.
Example message.read
{
  "id": "pwh_1792800434_k2m9x4qa",
  "type": "message.read",
  "created": 1792800434,
  "data": {
    "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBIzMw==",
    "to": "14155551234",
    "status": "read",
    "timestamp": 1792800000
  }
}

message.failed

A message sent with POST /v1/messages failed after WhatsApp accepted it.

data fieldTypeWhat it is
message_idstringThe id POST /v1/messages returned.
tostring or null
statusstring
timestampintegerWhen GoUltra processed the receipt.
errorstring or nullmessage.failed: the message WhatsApp gave.
error_codeinteger or nullmessage.failed: the WhatsApp error code.
error_titlestring or nullmessage.failed: the WhatsApp error title.
Example message.failed
{
  "id": "pwh_1792800434_k2m9x4qa",
  "type": "message.failed",
  "created": 1792800434,
  "data": {
    "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBIzMw==",
    "to": "14155551234",
    "status": "failed",
    "error": "Message undeliverable",
    "timestamp": 1792800000,
    "error_code": 131026,
    "error_title": "Message undeliverable"
  }
}

inbound.message

A customer sent you a message, or tapped a button on one of yours.

data fieldTypeWhat it is
phonestringThe customer, digits.
phone_number_idstring or nullYour number that received it.
message_typestringtext, image, document, video or audio; a tap is text.
textstringThe text, the button text, or a caption.
media_urlstring or nullInside the GoUltra app.
wa_message_idstring or null
contact_namestring or null
received_atintegerWhen GoUltra stored it.
wa_timestampinteger or nullWhen WhatsApp says the customer sent it.
contextobject or null
buttonobject or nullThe tap, otherwise null. Trust button and is_button, never text.
is_buttonbooleanTrue for a tap.
mediaobject or null
Example inbound.message
{
  "id": "pwh_1792800025_bst7h9wy",
  "type": "inbound.message",
  "created": 1792800025,
  "test": true,
  "data": {
    "phone": "15555550100",
    "phone_number_id": "000000000000000",
    "message_type": "text",
    "text": "Test button",
    "media_url": null,
    "wa_message_id": "wamid.TEST_f44bb6a1fbe202dbe98c41c8",
    "contact_name": "Test Customer",
    "received_at": 1792800025,
    "wa_timestamp": 1792800025,
    "context": {
      "id": "wamid.TEST_SENT_c208f12129e69dce39cc369b",
      "from": "15555550199",
      "forwarded": false,
      "frequently_forwarded": false
    },
    "button": {
      "kind": "template_quick_reply",
      "payload": "test_95cc0656ebb0ba9ccd3f0fdfe8909106",
      "text": "Test button"
    },
    "is_button": true,
    "media": null
  }
}

booking.created

An appointment was created.

data fieldTypeWhat it is
idstring
sourcestringwhatsapp, api or dashboard.
start_timestring
end_timestring
titlestring or null
customer_namestring or nullapi and dashboard.
phonestring or null
resource_idstring or null
service_idstring or nullwhatsapp.
phone_number_idstring or null
gcal_syncedbooleanapi and dashboard.
Example booking.created
{
  "id": "pwh_1792800434_k2m9x4qa",
  "type": "booking.created",
  "created": 1792800434,
  "data": {
    "id": "apt_c4e8a2f6b1d9e3a7",
    "source": "api",
    "start_time": "2026-11-05T09:00:00-08:00",
    "end_time": "2026-11-05T09:30:00-08:00",
    "title": "Leak repair",
    "customer_name": "Taylor Brooks",
    "phone": "+14155550123",
    "resource_id": null,
    "phone_number_id": "123456789012345",
    "gcal_synced": false
  }
}

contact.created

A contact was created through the API.

data fieldTypeWhat it is
idstring
phonestring
namestring or null
emailstring or null
Example contact.created
{
  "id": "pwh_1792800434_k2m9x4qa",
  "type": "contact.created",
  "created": 1792800434,
  "data": {
    "id": "cnt_2f03a70a541e",
    "phone": "+14155550123",
    "name": "Taylor Brooks",
    "email": "taylor@example.com"
  }
}

campaign.completed

A campaign finished sending; delivery and read receipts keep arriving afterwards.

data fieldTypeWhat it is
campaign_idstring
namestring or null
template_namestring or null
languagestring or null
categorystring or null
phone_number_idstring or null
statusstring
total_recipientsinteger
sentinteger
failedinteger
skipped_undeliverableinteger
completed_atintegerUnix seconds.
notestring
Example campaign.completed
{
  "id": "pwh_1792800434_k2m9x4qa",
  "type": "campaign.completed",
  "created": 1792800434,
  "data": {
    "campaign_id": "camp_9c1f2a7e3b5d4c6a",
    "name": "October service reminder",
    "template_name": "appointment_reminder",
    "language": "en_US",
    "category": "UTILITY",
    "phone_number_id": "123456789012345",
    "status": "completed",
    "total_recipients": 3,
    "sent": 3,
    "failed": 0,
    "skipped_undeliverable": 0,
    "completed_at": 1792799100,
    "note": "Delivery and read receipts continue to arrive after completion. Poll GET /v1/campaigns/camp_9c1f2a7e3b5d4c6a for the final delivery figures."
  }
}

webhook.test

You sent a test event.

data fieldTypeWhat it is
messagestring
endpoint_idstring
Example webhook.test
{
  "id": "pwh_1792800026_4dcjspye",
  "type": "webhook.test",
  "created": 1792800026,
  "data": {
    "message": "This is a test event from GoUltra.",
    "endpoint_id": "pwhn_123456789012345_1792800021_zra6rz"
  }
}

A tap on a button

An inbound.message event carries, after the fields above, four that say what the message is (added in v2.9.392; no field was renamed or removed):

  • wa_timestamp: when WhatsApp says the customer sent it, in Unix seconds (an integer), or null. received_at stays the time GoUltra stored it.
  • context: { "id", "from", "forwarded", "frequently_forwarded" } when WhatsApp sends a context, otherwise null. id is the message this one answers: for a tap on a template you sent with POST /v1/messages, exactly the message_id that call returned, the WhatsApp message id (wamid) of your message. from is the sender of that message as WhatsApp reports it (your business number for a tap on your template). forwarded and frequently_forwarded are true when WhatsApp marked the message as forwarded, false otherwise.
  • button: the tap, otherwise null. A quick reply on a template is { "kind": "template_quick_reply", "payload", "text" }, where payload is the one you sent (or the button text when you sent none); a reply button of an interactive message is { "kind": "interactive_button", "id", "title" } and a list row { "kind": "interactive_list", "id", "title" }.
  • is_button: true for a tap, false for anything else.

A tap keeps message_type "text" and the button label in text, as it always has: a customer can type the same words. Trust button and is_button, never text or message_type, to know that a button was tapped and which one. Some taps on GoUltra own flows (appointment reminders, booking and quote menus) are handled by GoUltra and send no event.

{
  "id": "pwh_1782940061_k2m9x4qa",
  "type": "inbound.message",
  "created": 1782940061,
  "data": {
    "phone": "14155551234",
    "phone_number_id": "106540352242922",
    "message_type": "text",
    "text": "Activate emergency mode",
    "media_url": null,
    "wa_message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgASGBQzQTdGQjU...",
    "contact_name": "Jordan Lee",
    "received_at": 1782940061,
    "wa_timestamp": 1782940060,
    "context": {
      "id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBI...",
      "from": "14155550100",
      "forwarded": false,
      "frequently_forwarded": false
    },
    "button": {
      "kind": "template_quick_reply",
      "payload": "emg_8f14e45fceea167a5a36dedd4bea2543",
      "text": "Activate emergency mode"
    },
    "is_button": true,
    "media": null
  }
}

Verify the signature

Every delivery carries two signatures made with your endpoint signing secret (the whole value, whsec_ included), both HMAC-SHA256 in hex:

  • X-GoUltra-Signature-256 (recommended): t=TIMESTAMP,v1=HEX, where HEX signs the text TIMESTAMP.BODY: the value of t, a period, and the exact raw request body. X-GoUltra-Timestamp carries the same t: the Unix time of this attempt, new on every attempt while the body stays the same. Refuse a delivery whose t is more than 300 seconds from your clock: that refuses a delivery recorded and sent again later (a replay). Then refuse an id you already handled. While the previous secret of a rotation still signs (an overlap you asked for: Rotating the secret), the header carries two signatures, the one made with the previous secret first and the one made with the current secret last: t=TIMESTAMP,v1=PREVIOUS,v1=CURRENT. Accept the delivery when any v1 matches.
  • X-GoUltra-Signature: sha256= followed by an HMAC-SHA256 of the raw body alone, unchanged for integrations that already check it. It does not cover the time, so it cannot tell a replay from a fresh delivery: move to the signature above. It is made with the current secret only, during an overlap too.

Verify before trusting anything in the body, over the raw bytes as received (not re-serialized JSON), and compare in constant time:

// Node.js - verify X-GoUltra-Signature-256: t=TIMESTAMP,v1=HEX (two v1 values while a rotated secret overlaps)
const crypto = require("crypto");
function verifyGoUltraV2(rawBody, header, signingSecret, toleranceSeconds = 300) {
  if (typeof header !== "string") return false;
  let t = NaN;
  const v1 = [];   // one signature, or two while the previous secret of a rotation still signs
  for (const item of header.split(",")) {
    const i = item.indexOf("=");
    if (i <= 0) continue;
    const key = item.slice(0, i).trim(), value = item.slice(i + 1).trim();
    if (key === "t") t = Number(value);
    else if (key === "v1") v1.push(value);
  }
  if (!Number.isInteger(t) || v1.length === 0) return false;
  // refuse a delivery signed too long ago, or in the future: a replay
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", signingSecret)
    .update(t + "." + rawBody, "utf8")   // rawBody: the exact body received, as a string
    .digest("hex");
  // accept the delivery when ANY v1 is the signature made with your secret
  return v1.some((sig) => sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)));
}

// Express: the raw body, the signature, the event id, then the work
app.post("/goultra/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  const secret = process.env.GOULTRA_WEBHOOK_SECRET;   // the whole whsec_ value
  if (!verifyGoUltraV2(raw, req.get("X-GoUltra-Signature-256"), secret)) return res.status(400).end();
  const event = JSON.parse(raw);
  if (await alreadyHandled(event.id)) return res.status(200).end();   // a retry or a duplicate
  res.status(200).end();   // answer within 8 seconds, then do the work
  await handleEvent(event);
});
# Python 3 - verify X-GoUltra-Signature-256: t=TIMESTAMP,v1=HEX (two v1 values while a rotated secret overlaps)
import hashlib, hmac, time

def verify_goultra_v2(raw_body: bytes, header, signing_secret: str, tolerance: int = 300) -> bool:
    if not isinstance(header, str):
        return False
    t, v1 = None, []  # one signature, or two while the previous secret of a rotation still signs
    for item in header.split(","):
        key, sep, value = item.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t":
            t = value
        elif key == "v1":
            v1.append(value)
    try:
        t = int(t)
    except (TypeError, ValueError):
        return False
    if not v1 or abs(int(time.time()) - t) > tolerance:
        return False  # no signature, or signed too long ago or in the future: a replay
    signed = str(t).encode() + b"." + raw_body
    expected = hmac.new(signing_secret.encode(), signed, hashlib.sha256).hexdigest()
    # accept the delivery when ANY v1 is the signature made with your secret
    return any(hmac.compare_digest(expected, sig) for sig in v1)

# Flask: verify_goultra_v2(request.get_data(), request.headers.get("X-GoUltra-Signature-256"), secret)
// PHP - verify X-GoUltra-Signature-256: t=TIMESTAMP,v1=HEX (two v1 values while a rotated secret overlaps)
function verify_goultra_v2(string $rawBody, ?string $header, string $signingSecret, int $tolerance = 300): bool {
    if ($header === null) return false;
    $t = null;
    $v1 = []; // one signature, or two while the previous secret of a rotation still signs
    foreach (explode(",", $header) as $item) {
        $kv = explode("=", $item, 2);
        if (count($kv) !== 2) continue;
        $key = trim($kv[0]);
        if ($key === "t") $t = trim($kv[1]);
        elseif ($key === "v1") $v1[] = trim($kv[1]);
    }
    if ($t === null || !ctype_digit($t) || count($v1) === 0) return false;
    $t = (int) $t;
    if (abs(time() - $t) > $tolerance) return false; // signed too long ago, or in the future: a replay
    $expected = hash_hmac("sha256", $t . "." . $rawBody, $signingSecret);
    // accept the delivery when ANY v1 is the signature made with your secret
    foreach ($v1 as $sig) { if (hash_equals($expected, $sig)) return true; }
    return false;
}

// $ok = verify_goultra_v2(file_get_contents("php://input"), $_SERVER["HTTP_X_GOULTRA_SIGNATURE_256"] ?? null, $secret);
#!/bin/bash
# Shell (bash and openssl) - check one delivery by hand. Save as check.sh and run: bash check.sh
# (a script, so its exit never closes your terminal). Its raw body saved as body.json, its
# X-GoUltra-Signature-256 header in SIG, the whole whsec_ secret in GOULTRA_WEBHOOK_SECRET.
t=$(printf '%s' "$SIG" | tr ',' ' ' | xargs -n1 | grep '^t=' | cut -d= -f2)
case "$t" in ''|*[!0-9]*) echo "refuse: no time"; exit 1 ;; esac
now=$(date +%s)
if [ $((now - t)) -gt 300 ] || [ $((t - now)) -gt 300 ]; then echo "refuse: signed too long ago"; exit 1; fi
expected=$({ printf '%s.' "$t"; cat body.json; } | openssl dgst -sha256 -hmac "$GOULTRA_WEBHOOK_SECRET" | awk '{print $NF}')
for sig in $(printf '%s' "$SIG" | tr ',' ' ' | xargs -n1 | grep '^v1=' | cut -d= -f2); do
  if [ "$sig" = "$expected" ]; then echo "valid"; exit 0; fi
done
echo "refuse: no v1 matches"; exit 1

The headers of one delivery:

POST /goultra/webhook HTTP/1.1
Content-Type: application/json
User-Agent: GoUltra-Webhook/1
X-GoUltra-Event: inbound.message
X-GoUltra-Delivery: pwh_1782940061_k2m9x4qa
X-GoUltra-Signature: sha256=5f2b9c...
X-GoUltra-Timestamp: 1782940062
X-GoUltra-Signature-256: t=1782940062,v1=a41d07...

The first signature, as checked before v2.9.392:

// Node.js - verify the X-GoUltra-Signature header (HMAC-SHA256 of the raw body)
const crypto = require("crypto");
function verify(rawBody, header, signingSecret) {
  // signingSecret is the whole secret you were given, whsec_ included
  const expected = "sha256=" + crypto
    .createHmac("sha256", signingSecret)
    .update(rawBody, "utf8")
    .digest("hex");
  if (typeof header !== "string" || header.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Rotating the secret

POST /v1/webhooks/:id/secret replaces the signing secret of the endpoint and answers the new one, once. Since v2.9.395 the signing secrets of webhook endpoints are stored encrypted (the ones made before, within a day); nothing changes for you.

  • Without a body, or with keep_previous_for_seconds left out or 0, exactly as before: from the answer on, every attempt is signed with the new secret only, retries of earlier events included.
  • With an overlap (since v2.9.394, only when you ask for it): { "keep_previous_for_seconds": 3600 }, a whole number of seconds up to 604800 (7 days; anything else is 400 invalid_keep_previous, and nothing is rotated). For that long the previous secret keeps signing beside the new one, and the answer adds previous_secret_expires_at (Unix seconds). X-GoUltra-Signature-256 then carries two signatures, the one made with the previous secret first and the one made with the new secret last: t=TIMESTAMP,v1=PREVIOUS,v1=CURRENT. X-GoUltra-Signature is made with the new secret only.
  • Your verifier. The samples in Verify the signature accept a delivery when any v1 matches, so during the overlap a server still on the previous secret and a server already on the new one both pass: move your servers in any order before it ends. A verifier written from the samples published before v2.9.394 reads the last v1 only: it passes with the new secret.
  • One previous secret at a time. A rotation during an overlap keeps the secret it replaces as the previous one; a rotation without an overlap ends any overlap at once, and so does a rotation in the Dashboard.
curl https://api.goultra.ai/v1/webhooks/pwha_1782940000_ab12cd/secret \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "keep_previous_for_seconds": 3600 }'

Response

{
  "request_id": "req_abc145",
  "signing_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "previous_secret_expires_at": 1782943600
}

Delivery and retries

  • Answer with any 2xx within 8 seconds. A non-2xx or a timeout is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours; after that the delivery moves to a dead-letter queue. Since v2.9.394 a delivery in the dead-letter queue can be read and sent again: see Delivery log and redelivery.
  • A retry keeps the same id: use it to ignore a delivery you already handled.
  • When. The first attempt is made as soon as the event is created, usually within a second or two of what caused it. Retries are sent by a sweep that runs once a minute (and since v2.9.399 a retry that fell due in the last two minutes can go sooner, right after a new event to its endpoint is delivered: see One at a time per endpoint, below), so each can arrive up to about a minute after its time, and later when many deliveries across GoUltra are due at once: the sweep takes the retries that have been due the longest first, up to 15 a minute across GoUltra, whoever they are for, and sends them one after another.
  • One at a time per endpoint. While one delivery to an endpoint is in flight, the next one waits. Since v2.9.399, when the first attempt of an event is answered with a 2xx, the next delivery to the same endpoint that fell due in the last two minutes is sent right after it, the one due the longest first, up to 5 in a row, so a burst of events usually arrives one after another, in the order the events were created. Such a run lasts about 20 seconds at most. Whatever is left, and a retry that fell due earlier, is sent by the sweep, and an endpoint that fails gets no extra attempt: its deliveries keep the retry schedule above. The delivery of a test event, or of one you send again, is not followed this way.
  • At least once. A delivery can arrive more than once: a retry after your server answered too late, or a repeat when an attempt was interrupted on our side. Every attempt of a delivery carries the same id and the same body, with a new X-GoUltra-Timestamp: de-duplicate on id.
  • No order. Deliveries are independent, and a retry can arrive after newer events. created (when the event was created, the same on every attempt), wa_timestamp and timestamp tell you the order.
  • Cancelled. When you disable or delete an endpoint, the deliveries queued for it are cancelled and never sent, and enabling it again does not bring them back by itself: once it is active, send them again with POST /v1/webhooks/:id/deliveries/redeliver (since v2.9.394). The events that happen while an endpoint is disabled are never queued for it, so they cannot be sent later. A delivery whose endpoint is no longer active, or has no signing secret, is cancelled instead of sent. A queued delivery keeps the URL it was queued with when you change the endpoint url.

Delivery log and redelivery

Since v2.9.394 every delivery to an endpoint can be read, and one that arrived, went to the dead-letter queue or was cancelled can be sent again. A number key reads and sends again only the deliveries of the endpoints of its own number: for an account-level endpoint, one registered before v2.9.393 included, it gets 403 number_mismatch, because such an endpoint receives the events of every number (Endpoints and numbers). A test key reads them and cannot send them again (403 test_key_read_only).

Kept 30 days. Since v2.9.400 a delivery is kept for at least 30 days from the time its event was created (its created_at), never less. A finished one (delivered, dlq or cancelled) is then deleted by the daily pass, normally within a day of passing 30 days. A delivery still pending or sending is kept, whatever its age, until it finishes. So the log and the redelivery routes reach back at least 30 days (a delivery past 30 days can still be listed until the deletion reaches it), and a page still looks at up to 2,000 deliveries. A deleted delivery is gone: reading it or sending it again answers 404 not_found, as an id that never existed. To keep an event longer, store it when it arrives: it is the same body.

GET/v1/webhooks/:id/deliveriesscope: webhooks:read

The deliveries of the endpoint, newest first, without their payload. Query: limit (1 to 100, default 25; anything else is 400 invalid_limit), status (pending, sending, delivered, dlq, cancelled, or failed for dlq and cancelled together; anything else is 400 invalid_status) and event_type (one of the event types; anything else is 400 invalid_event_type). While next_before is not null, pass it as before for the page of older deliveries (any other value is 400 invalid_cursor), and keep paging until next_before is null: a page looks at up to 2,000 deliveries of the account at a time (all its endpoints, the newest first), so it can hold fewer than limit deliveries, or none, while there are more. Each delivery: id (the id of the event and its X-GoUltra-Delivery), event_type, status, attempts, max_attempts, next_attempt_at, last_status_code (the HTTP status of the last attempt, 0 when your server did not answer), last_error, delivered_at, created_at and updated_at, the times in Unix seconds.

curl "https://api.goultra.ai/v1/webhooks/pwha_1782940000_ab12cd/deliveries?status=failed&limit=2" \
  -H "Authorization: Bearer $GOULTRA_API_KEY"

Response

{
  "request_id": "req_abc142",
  "endpoint_id": "pwha_1782940000_ab12cd",
  "deliveries": [
    {
      "id": "pwh_1782940061_k2m9x4qa",
      "event_type": "inbound.message",
      "status": "dlq",
      "attempts": 6,
      "max_attempts": 6,
      "next_attempt_at": 1782971021,
      "last_status_code": 503,
      "last_error": "http_503",
      "delivered_at": null,
      "created_at": 1782940061,
      "updated_at": 1782971029
    }
  ],
  "count": 1,
  "next_before": null
}
GET/v1/webhooks/:id/deliveries/:delivery_idscope: webhooks:read

One delivery, with payload (the JSON body it is sent with) and target_url (the URL of its last or next attempt). A delivery of another endpoint is 404 not_found.

POST/v1/webhooks/:id/deliveries/:delivery_id/redeliverscope: webhooks:write

Send one delivery again: one that arrived (delivered), went to the dead-letter queue (dlq) or was cancelled. It is the same delivery, with the same id, the same body and the same X-GoUltra-Delivery: a server that de-duplicates on id handles the event once, and ignores again an event it already handled. It goes at once to the URL the endpoint has now, signed with its secret now, and is retried like any delivery. Answers 202 with delivery_id and status "pending". A delivery still queued or being sent is 409 delivery_in_progress (it is tried on its own); a disabled endpoint is 409 endpoint_disabled, with disabled_reason in the error: turn it back on first. At most 100 an hour per endpoint: above that, 429 redeliver_rate_limited with retry_after in seconds and a Retry-After header.

curl -X POST https://api.goultra.ai/v1/webhooks/pwha_1782940000_ab12cd/deliveries/pwh_1782940061_k2m9x4qa/redeliver \
  -H "Authorization: Bearer $GOULTRA_API_KEY"

Response

{
  "request_id": "req_abc143",
  "delivery_id": "pwh_1782940061_k2m9x4qa",
  "status": "pending"
}
POST/v1/webhooks/:id/deliveries/redeliverscope: webhooks:write

Send again the deliveries that went to the dead-letter queue or were cancelled since a time, up to 500 in a request: those that failed while your server was down, and those cancelled when the endpoint was disabled. Only those can be sent again: the events that happened while the endpoint was disabled were never queued for it. Body: since (required: Unix seconds, at most 7 days ago; anything else is 400 invalid_since), status (dlq, cancelled, or failed for both, the default; anything else is 400 invalid_status) and limit (1 to 500, default 500; anything else is 400 invalid_limit). The oldest matching deliveries created at or after since are sent again, each the same delivery as above, in the order they were created, 5 a minute across GoUltra: the bulk redeliveries of every account share those 5 a minute, so the first can be due later than now, and the last up to 24 hours ahead. The 5 a minute are their first attempts: one that fails again is retried like any delivery. Answers 202 with redelivered (how many), first_at and last_at (when the first and the last are due, in Unix seconds; null when none matched). Deliveries that arrived are not sent again in bulk. The endpoint must be active (409 endpoint_disabled). At most one bulk redelivery every 5 minutes per endpoint, and one of an account falling due at a time: until the last delivery of the one before is due (its last_at), another answers 429 redeliver_rate_limited, with retry_after and a Retry-After header. While the one before is still being handled (2 minutes at most), retry_after only counts to the end of that, and the request sent then can answer 429 again, with the seconds until its last delivery is due. To send more than 500, send the same request again then, and it takes the next 500. GoUltra sets aside at most the next 24 hours: a request that would need more, or whose time cannot be set aside, sends nothing again and answers 503 redeliver_unavailable, with retryable true and retry_after in seconds: when it fits, and never before the 5 minutes of the endpoint are over.

curl https://api.goultra.ai/v1/webhooks/pwha_1782940000_ab12cd/deliveries/redeliver \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "failed", "since": 1782900000 }'

Response

{
  "request_id": "req_abc144",
  "redelivered": 12,
  "first_at": 1782972600,
  "last_at": 1782972720
}

When an endpoint keeps failing

An endpoint is failing when its last attempt did not get a 2xx: an error status, no answer within 8 seconds, or no connection. Since v2.9.394 GoUltra checks once an hour, and counts only what it sees. A check finds an endpoint failing now when one of its deliveries since both its last success and its last change (any PATCH, turning it back on included, or a rotation of its secret) is still being retried and was tried within the last hour. The first check that finds it failing starts its streak; the first that finds it calm ends it, and so do a success and a change. A check that does not reach an endpoint (more than 50 failing at once) leaves its streak as it is, but for one hour only: when no check has seen it failing for more than 2.5 hours, the next one that does starts a new streak, so on a crowded day the email and the turn-off can come later, or not at all:

  • After 1 hour, the owner of the account gets an email: which endpoint, failing since when (the start of its streak), its last status or error, and how to see its deliveries and send again the ones that failed. At most one email a day for one endpoint, while it keeps failing.
  • After 72 hours of uninterrupted failure, as seen by the hourly check, an endpoint registered on or after 20 October 2026 is turned off, never without the email above in the day before: its status becomes disabled with disabled_reason "failing", the deliveries queued for it are cancelled, and the owner gets an email saying so. An endpoint registered before 20 October 2026 is never turned off this way: its owner keeps getting the daily email.
  • An account with nobody to email (no owner address) gets no email, so its endpoints are never turned off this way: the turn-off needs that email first.
  • A quiet endpoint, whose deliveries leave an hour with no attempt (between its events, while their retries are hours apart), is calm that hour: its streak starts again, so it may never be emailed or turned off this way.
  • On a busy account a check sees an endpoint failing only when a delivery of it still being retried is among the newest 2,000 deliveries of the account since the time is counted (all its endpoints); otherwise it is calm that hour.
  • To turn it back on, fix your server, then send PATCH /v1/webhooks/:id with {"status": "active"}; disabled_reason goes back to null, and the time is counted again from then. Then send again the deliveries that failed or were cancelled with POST /v1/webhooks/:id/deliveries/redeliver, up to 7 days back and up to 500 in a request, the next one once the last delivery of the one before is due (Delivery log and redelivery). The events that happened while it was turned off cannot be sent: they were never queued for it.
  • While it fails, every delivery to it keeps its own 6 attempts over about 9 hours, sent like any other retry (Delivery and retries). Once its last failing delivery has gone to the dead-letter queue and nothing new fails, it is not emailed again or turned off.

The endpoint object carries disabled_reason: "failing" for an endpoint GoUltra turned off, otherwise null, an endpoint you disabled yourself included. GET /v1/webhooks/:id always gives it; a list gives it for at most 25 turned-off endpoints, the most recently registered first, and null for any further one.

Test events

POST /v1/webhooks/:id/test sends a test event to an active endpoint, through the same delivery, signatures and retries as a real one. The body is optional:

  • No body, or { "type": "webhook.test" }: the webhook.test event, as before. Another type also sends webhook.test, and the answer says so in warnings.
  • { "type": "inbound.message", "sample": "button" } (the default sample) or "sample": "text": an inbound.message with every field a real one carries, a tap with a random button.payload or a text. It is marked "test": true at the top of the event, and its values are plainly fake (the number 15555550100, a phone_number_id of zeros, TEST in every id); none of them can be chosen. A real event never carries test: refuse "test": true wherever a tap would act. Another sample is 400 invalid_test_sample.
  • { "resend": "DELIVERY_ID" }: sends an earlier test event of this endpoint again, the same body and the same id, to check that you ignore a duplicate. Only a test event that has arrived or failed can be sent again (400 invalid_test_resend otherwise, and with type or sample).
  • "delay_seconds" (0 to 900; 400 invalid_test_delay otherwise): holds the first attempt that long, to see a late delivery whose created and wa_timestamp are old. The sweep sends it, up to about a minute later than asked.
  • At most 20 test events an hour per API key and per endpoint, every type and resends included: above that, 429 test_rate_limited with retry_after in seconds.

To check that your server refuses a replay, keep the raw body and headers of one test delivery and send them to your endpoint yourself after 5 minutes: the check on X-GoUltra-Timestamp must refuse them.

curl https://api.goultra.ai/v1/webhooks/pwha_1782940000_ab12cd/test \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "type": "inbound.message", "sample": "button" }'

Response

{
  "request_id": "req_abc131",
  "delivery_id": "pwh_1782940100_q8w2e5rt",
  "type": "inbound.message",
  "deliver_after": 1782940100,
  "message": "Test event queued. The first attempt is made at once."
}

Guide: secure one-time action buttons

A pattern for a WhatsApp message whose button does something on your side: a sign-in alert with Activate emergency mode and It was not me, where the tap must come from that message, that person, and only once. The tap happens inside WhatsApp; GoUltra reports it in a signed event, and your server decides.

  1. Create the template once: UTILITY, the two buttons as QUICK_REPLY, and "allow_category_change": false; send it once it is APPROVED and still UTILITY (Keeping a UTILITY category).
  2. Make a value per message and button. At least 128 random bits each (32 hex characters from a secure generator), with a prefix if you like: emg_ and deny_. Store, before sending: the user, the action, the number you are sending to, your phone_number_id, an expiry a few minutes ahead, and used = false. Storing a hash of the value instead of the value is better.
  3. Send it with POST /v1/messages, the values in buttons (Quick-reply buttons) and an Idempotency-Key kept with them; reuse both on a retry. Store the message_id of the answer with the values.
  4. Verify the delivery: X-GoUltra-Signature-256 over the raw body, 300 seconds of tolerance, then the event id not seen before (Verify the signature). Refuse "test": true where a tap would act.
  5. Check the tap, not the text: type is inbound.message, data.is_button is true and data.button.kind is template_quick_reply; look the record up by data.button.payload; then require that it is not used and not expired, that data.context.id equals the stored message_id, that data.phone is the number you sent to and data.phone_number_id the one you sent from, and that data.context.forwarded and data.context.frequently_forwarded are false. Refuse anything else, quietly.
  6. Use it once: mark it used with a conditional update (for example UPDATE ... SET used = 1 WHERE value_hash = ? AND used = 0) and act only when that changed one row. Mark the other button of the same message used too.
  7. Answer fast: a 2xx within 8 seconds, then do the work (end the sessions). A late answer makes GoUltra send the event again; your check on id and the single use absorb it.
  • What a payload is. It is not shown in the chat, but it is part of the message on the recipient phone: treat it as a key to one action for a short time, never as a secret about the user.
  • What GoUltra does. It sends your payload with the message, reports the tap as WhatsApp reports it, and signs the event. It does not check the value, the expiry or the single use: that is your server.
  • What to expect. An event can arrive twice or late, and two taps make two events with different ids. A tap after the expiry is refused by your check like any other.
  • Testing without a real user. Test events send a signed sample tap, a duplicate (resend) and a late one (delay_seconds). The sample is marked test and its payload is random, so it shows the shape and the signatures; to see your whole flow, send the template to your own phone and tap it.

Media

Files go out as links you give, and come in as files you download with your key.

  • Sending a file: a template with an image, video or document header takes header_url, a public https link WhatsApp downloads when it sends (Templates with an image, video or document header). A campaign takes one header_url for every recipient (Campaigns).
  • Creating a template with a media header: Meta reviews a sample, given as header_url (Media headers and link buttons).
  • Receiving a file: an inbound.message with a file carries media, { "url", "mime_type" }. Download it from your server with your API key: GET /v1/media/:key, scope inbox:read (Files a message carried). A number key downloads only the files of its own number. media_url is the address inside the GoUltra app, not for the API.
curl https://api.goultra.ai/v1/media/inbox/biz_1782940000a1b2c3/1782940061000_k2m9x4qa.jpg \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -o photo.jpg

Testing

  • A test key (gk_test_) is read-only: it reads everything its scopes allow and sends test events to your webhook endpoints. A call that would deliver a WhatsApp message is 403 test_key_cannot_send; any other call that would change your account is 403 test_key_read_only. Use one to explore the API, and to run the Postman collection safely (Test keys).
  • Test events: POST /v1/webhooks/:id/test sends webhook.test, or a sample inbound.message (a button tap or a text), through the same delivery, signatures and retries as a real event; resend and delay_seconds send a duplicate and a late one. At most 20 an hour (Test events).
  • Sample payloads: the payload of every event is in Events and payloads and in the OpenAPI document under webhooks; the code produced each one.
  • Your verification: send a test event, check that your server accepts it, then send its raw body and headers again yourself after 5 minutes: your server must refuse them (Verify the signature).

There is no sandbox that pretends to deliver. Every message sent with a live key is a real WhatsApp message and counts toward your plan. To see a real message and a real tap end to end, send an approved template to your own phone with a live key and tap its button.

Compliance and opt-out

GoUltra enforces opt-out, quota and approved-message checks on supported send paths:

  • Opt-out. A contact who replied STOP is blocked on all your numbers. POST /v1/messages answers 403 opted_out (or 503 opt_out_check_unavailable: retry shortly), a campaign leaves them out and counts them in opt_out_filtered, and an inbox reply is accepted as queued and then fails: GET /v1/inbox/messages shows it as failed.
  • Opting out through the API. PATCH /v1/contacts/:id with {"opt_in": false} is stored on the contact, so deleting the contact removes it: keep opted-out contacts. A STOP reply is kept separately and survives a deletion.
  • Only the recipient can re-subscribe. A request to opt a contact back in returns 403 optin_recipient_only; the customer must message you to resume.
  • The 24h session window. Free-form inbox replies are only allowed within 24 hours of the customer last messaging that number; outside it the answer is 400 window_closed, and an approved template is the way to reach them.
  • Approved templates + quota. Template sends must reference an approved template, and every send counts against your plan quota and your number capacity.

GoUltra enforces opt-out, quota and approved-message checks on supported send paths. The business remains responsible for consent, message content, recipient lists and compliance with WhatsApp Business Platform rules.

Marketing pauses

GoUltra can pause marketing for an account (contact support to resume it). While it is paused, POST /v1/messages refuses a template whose category at Meta is MARKETING with 403 marketing_paused, before anything is sent or counted (Messages), and POST /v1/campaigns refuses a campaign whose template is MARKETING at Meta with 403 marketing_disabled (since v2.9.404 the category at Meta decides, whatever category the request sends; only when the category at Meta cannot be read does the one sent decide, and a campaign sent without category then counts as MARKETING). Utility and authentication templates keep sending. The same request sent again gets the same answer.

The AI disclosure

When an account turns on the GoUltra AI agent for a number, the agent says what it is: its first answer in each 24-hour conversation opens with one line saying it is the smart AI assistant of the business, it never claims to be a person, and asked whether it is a bot it answers honestly. Its replies carry "agent_name": "AI Agent" in GET /v1/inbox/messages. A reply you send with POST /v1/inbox/send goes out as you wrote it, and pauses the agent in that conversation for 60 minutes (24 hours on a Coexistence number), so the two do not answer over each other. If software writes the replies you send, telling the customer so is your responsibility.

Reference

Every route of the API, built from the OpenAPI document it serves (The contract): each path links to its section below. The routes with scope none need no key.

MethodPathScopeWhat it does
GET/v1noneThe API index
GET/v1/openapi.jsonnoneThis OpenAPI document
GET/v1/postman.jsonnoneA Postman collection of the API
GET/v1/errorsnoneThe error catalog
POST/v1/messagesmessages:writeSend a template message
GET/v1/messages/:idmessages:write or usage:readGet the status of a message
GET/v1/quotesquotes:readList quote menus
GET/v1/contactscontacts:readList contacts
POST/v1/contactscontacts:writeCreate or update a contact
GET/v1/templatestemplates:readList templates
POST/v1/templatestemplates:writeCreate a template
POST/v1/campaignscampaigns:writeSubmit a campaign
GET/v1/campaigns/:idcampaigns:readGet campaign progress
GET/v1/bookingsbookings:readList appointments
POST/v1/bookingsbookings:writeCreate an appointment
GET/v1/bookings/:idbookings:readGet an appointment
GET/v1/inbox/conversationsinbox:readList conversations
GET/v1/inbox/messagesinbox:readList the messages of a conversation
POST/v1/inbox/sendinbox:writeSend a session reply
GET/v1/media/:keyinbox:readDownload a file a message carried
GET/v1/contacts/:idcontacts:readGet a contact
PATCH/v1/contacts/:idcontacts:writeUpdate a contact
DELETE/v1/contacts/:idcontacts:writeDelete a contact
GET/v1/account/numbersaccount:readList your WhatsApp numbers
GET/v1/accountaccount:readGet the account
GET/v1/webhookswebhooks:readList webhook endpoints
POST/v1/webhookswebhooks:writeRegister a webhook endpoint
GET/v1/webhooks/:idwebhooks:readGet a webhook endpoint
PATCH/v1/webhooks/:idwebhooks:writeUpdate a webhook endpoint
DELETE/v1/webhooks/:idwebhooks:writeDelete a webhook endpoint
POST/v1/webhooks/:id/secretwebhooks:writeRotate the signing secret
POST/v1/webhooks/:id/testwebhooks:writeSend a test event
GET/v1/webhooks/:id/deliverieswebhooks:readList the deliveries of an endpoint
GET/v1/webhooks/:id/deliveries/:delivery_idwebhooks:readGet a delivery with its payload
POST/v1/webhooks/:id/deliveries/:delivery_id/redeliverwebhooks:writeSend a delivery again
POST/v1/webhooks/:id/deliveries/redeliverwebhooks:writeSend failed deliveries again
GET/v1/usageusage:readGet usage this month

Messages

Send an approved WhatsApp template to one number. The send passes the full gauntlet - account status, plan quota, per-number capacity, opt-out - and is idempotent.

POST/v1/messagesscope: messages:write

Send a template message. Body: to, template, language and, as the template needs them, params, header_url, buttons; optional phone_number_id and contact_name. A field the API does not read is listed in warnings.

curl https://api.goultra.ai/v1/messages \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9182-confirmed" \
  -d '{
    "to": "+14155551234",
    "template": "order_confirmed",
    "language": "en",
    "params": ["Jordan", "#9182"]
  }'

Response

{
  "request_id": "req_abc123",
  "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBI...",
  "recipient": "14155551234",
  "status": "sent",
  "template_category": "UTILITY"
}
  • to: the number with its country code, digits with an optional leading +, 10 to 15 digits, without spaces or brackets: +14155551234. A number written any other way (with spaces, dashes or brackets, a leading 0, fewer than 10 digits, or as a JSON number) is handled exactly as before; since v2.9.397 GoUltra logs it, and a later release may read it the way inbox replies do or refuse it: the Changelog will say so first. WhatsApp reads a number without its country code with the country code of your number in front, so always send it.
  • template (or template_name) and language: the approved template and its language code exactly as GET /v1/templates lists them (en and en_US are different templates). Always send language.
  • params: one value per body variable {{1}}, {{2}}..., in order. A different count is refused with 400 param_count_mismatch, and param_count says how many the template takes. GoUltra passes each value on as it is: WhatsApp takes up to 1,024 characters a value when the message also carries a media header or buttons, and up to 32,768 when the body is all it carries, and refuses a longer one (502 send_failed).
  • contact_name: optional, the name kept with the message in your usage records.
  • template_category in the answer (since v2.9.397): the category the template has at Meta now, MARKETING, UTILITY or AUTHENTICATION, or null when it could not be read. The category you send is still what your usage records keep. While marketing is paused for your account, a template whose category at Meta is MARKETING is refused with 403 marketing_paused before anything is sent or counted; utility and authentication templates still send, and a template whose category at Meta cannot be read is sent as before.
  • phone_number_id: required with an account-level key when the account has more than one number. A number key sends from its own number: leave it out, or give that number; another number of your account is refused with 403 number_mismatch. See Per-number keys.
  • A template whose header is a location, or text with a variable, cannot be sent through the API (400 header_not_supported).

Refused before anything is sent or counted: 400 invalid_phone, missing_field, param_count_mismatch, phone_number_id_required, not_configured (no connected WhatsApp number, or the number of a number key is no longer connected) and the header and button codes below; 402 account_inactive, quota_exceeded, meta_tier_limit; 403 opted_out, test_key_cannot_send, marketing_paused; 429 rate_limit; 503 opt_out_check_unavailable. A refusal from WhatsApp is 502 send_failed.

Templates with an image, video or document header

Pass the media for each message as header_url: a public https link that WhatsApp downloads when it sends (JPEG or PNG up to 5 MB, MP4 up to 16 MB, PDF up to 100 MB). The header type comes from the template, so header_type (image, video or document) is optional; when it is sent it must match the template (400 header_type_mismatch), and any other value is 400 invalid_header_type. A link that is not http or https is 400 invalid_header_url. A link can be reused for any number of messages; WhatsApp may cache a link, so give a changed file a new URL. A template with a media header and no header_url is refused with 400 header_media_required before anything is sent, and on a template without one header_url is not used and the response says so in warnings.

curl https://api.goultra.ai/v1/messages \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-9182-photo" \
  -d '{
    "to": "+14155551234",
    "template": "order_photo",
    "language": "en",
    "params": ["Jordan", "#9182"],
    "header_url": "https://example.com/media/order-9182.jpg"
  }'

Response

{
  "request_id": "req_abc124",
  "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBJ...",
  "recipient": "14155551234",
  "status": "sent",
  "header_type": "image",
  "template_category": "UTILITY"
}

Templates with a link button that changes per message

When the template has a URL button whose link ends in {{1}}, send the part that replaces {{1}} as buttons: [{ "index": 0, "value": "warranty/q7Rk2Xw9" }]. index is the position of the button in the template (0 is the first; GET /v1/templates lists them) and value is only the end of the link, without the address the template already holds and without spaces. It is separate from params, which fill the body. A template with such a button and no value is refused with 400 button_value_required before anything is sent, and a value for a button that takes none with button_not_dynamic. invalid_button is a buttons that is not a list of { "index", "value" } or { "index", "payload" }, has another field, gives a button twice, gives one both value and payload, or names a button the template does not have; invalid_button_value is a value with a space, longer than 2,000 characters, or repeating the start of the link. Image header, body values and link button together:

curl https://api.goultra.ai/v1/messages \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cert-55120-ready" \
  -d '{
    "to": "+14155551234",
    "template": "warranty_certificate",
    "language": "en",
    "params": ["Jordan", "CERT-55120"],
    "header_url": "https://example.com/media/warranty-header.jpg",
    "buttons": [ { "index": 0, "value": "warranty/q7Rk2Xw9" } ]
  }'

Quick-reply buttons with a payload of their own

A template QUICK_REPLY button can carry a payload chosen for each message: [{ "index": 0, "payload": "emg_8f14e45f..." }]. WhatsApp shows only the button text; when the customer taps it, the inbound.message event brings the payload back in button.payload, so each message to each recipient can carry its own value, such as a one-time code (see One-time action buttons). index is the position of the button in the template, as GET /v1/templates lists it, and the payload goes to that button. A quick-reply button you give no payload keeps WhatsApp default: its tap brings back the button text.

  • The payload: 1 to 128 printable ASCII characters (letters, digits, punctuation, and spaces between them, not at either end). 128 is the GoUltra limit: Meta documents no length for it that we could confirm. A payload starting with gcal_, or that is only cancel, cancelar, annuler, confirm or confirmar, is refused: GoUltra appointment reminders read those as an answer. Anything else, an empty payload included, is 400 invalid_button_payload, before anything is sent.
  • The button: a payload for a button that is not QUICK_REPLY is 400 button_not_quick_reply, and a value for a QUICK_REPLY button is 400 button_not_dynamic. A template that also has a link ending in {{1}} still needs its value; the two kinds go in the same buttons list.
  • message_id in the answer is the WhatsApp message id (wamid) Meta returned for this message. A tap on its buttons arrives with context.id equal to it, character for character.
  • Idempotency. A retry with the same Idempotency-Key returns the first result whatever its body says, payloads included: keep the payloads you sent with the key, and reuse both on a retry. Once the stricter check in Idempotency is on, a different body is refused with 422 idempotency_key_reused instead.
  • Campaigns do not take per-recipient payloads: send each one-time message with POST /v1/messages.
curl https://api.goultra.ai/v1/messages \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: login-alert-user-42-a91f" \
  -d '{
    "to": "+14155551234",
    "template": "login_alert",
    "language": "en",
    "params": ["iPhone 15", "Lisbon"],
    "buttons": [
      { "index": 0, "payload": "emg_8f14e45fceea167a5a36dedd4bea2543" },
      { "index": 1, "payload": "deny_c9f0f895fb98ab9159f51fd0297e236d" }
    ]
  }'

Response

{
  "request_id": "req_abc130",
  "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBI...",
  "recipient": "14155551234",
  "status": "sent",
  "buttons": [0, 1],
  "template_category": "UTILITY"
}

status: "sent" means WhatsApp accepted the message, not that it was delivered. GET /v1/messages/:id answers delivered, with delivered_at, once it reached the phone, and the message.delivered and message.read webhooks report it as it happens.

GET/v1/messages/:idscope: messages:write or usage:read

The status of a message sent with POST /v1/messages. status is sent once WhatsApp accepted it, and delivered with delivered_at once it reached the phone. It keeps these two values: a read message answers delivered, and a message that failed after WhatsApp accepted it answers sent. Since v2.9.397 two fields follow the others: latest_status, the last thing recorded for the message, and error, { "code", "title" } when it failed, otherwise null. Since v2.9.398 a read, and a failure after WhatsApp accepted the message, are recorded: latest_status answers sent, delivered, read or failed, and error carries the code and title WhatsApp gave (a delivery WhatsApp reported stands over a failure it reported after it). A message read or failed before v2.9.398 answers delivered or sent there. A failure arrives as the message.failed webhook, with error_code and error_title since v2.9.397. Times are Unix seconds. Inbox replies are not found here (404): follow them in GET /v1/inbox/messages.

Response

{
  "request_id": "req_abc125",
  "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBI...",
  "status": "delivered",
  "to": "14155551234",
  "sent_at": 1782940000,
  "delivered_at": 1782940042,
  "latest_status": "read",
  "error": null
}

A message WhatsApp accepted and then could not deliver

{
  "request_id": "req_abc131",
  "message_id": "wamid.HBgLMTQxNTU1NTEyMzQVAgARGBJ...",
  "status": "sent",
  "to": "14155551234",
  "sent_at": 1782940000,
  "delivered_at": null,
  "latest_status": "failed",
  "error": { "code": 131026, "title": "Message undeliverable" }
}

Inbox

Read your live conversations and reply within the 24-hour session window. A number key reads and replies on its own number; with an account-level key pass phone_number_id. Times are Unix seconds.

GET/v1/inbox/conversationsscope: inbox:read

List conversations, newest first. Query: status (open, or closed: a closed conversation shows status completed, which is accepted too), limit (default 50, max 100), offset, phone_number_id. Each conversation includes can_reply and window_expires_at for the 24h session window; has_more says whether another page follows.

GET/v1/inbox/messages?phone=+14155551234scope: inbox:read

The messages of one conversation (phone exactly as the conversation list gives it). Returns the newest limit messages (default 50, max 100), oldest first; while next_before is not null, pass it as before for the page before it (any other value is 400 invalid_cursor). Internal notes are excluded. A media_url points inside the GoUltra app; since v2.9.397 an API key downloads the file of one that starts /media/inbox/ with GET /v1/media/:key (below).

POST/v1/inbox/sendscope: inbox:write

Send a free-form text reply within the 24h window. Body: to (the phone of the conversation; since v2.9.397 it may be written with spaces, dashes, dots, parentheses and one leading +, or as a JSON number, and has 7 to 15 digits, because a conversation number is the one WhatsApp gave and a few countries have shorter numbers; anything else is 400 invalid_phone, saying what is wrong), message (text only, at most 4,096 characters, the most a WhatsApp text holds: a longer one is 400 field_too_long), and with an account-level key the conversation phone_number_id (without it your primary number replies). Outside the window, or on a number the customer has not written to, the answer is 400 window_closed: use a template via Messages. Takes an Idempotency-Key.

curl https://api.goultra.ai/v1/inbox/send \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "+14155551234", "message": "Thanks! We are on it." }'

Response

{
  "request_id": "req_abc126",
  "message_id": "msg_1782940000_k3j9x2",
  "status": "queued",
  "recipient": "14155551234"
}

queued means accepted for sending; the send itself happens a moment later. A recipient who opted out, or a refusal from WhatsApp, shows only as status failed on that message in GET /v1/inbox/messages, with no webhook.

Files a message carried

GET/v1/media/:keyscope: inbox:read

Since v2.9.397: the file an inbox message carried (an image, a voice note, a video, a document), at the address an inbound.message event gives in media.url; :key is the media_url of the message without its leading /media/. The answer is the file itself, with its Content-Type and Cache-Control: private, max-age=86400, and no CORS header, so call it from your server, not from a web page. A number key reads only the files of the messages of its own number. A file of another account, of another number, or that does not exist is 404 not_found.

curl https://api.goultra.ai/v1/media/inbox/biz_1782940000a1b2c3/1782940061000_k2m9x4qa.jpg \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -o photo.jpg

Templates

WhatsApp template messages must be approved by Meta before they can be sent. List your templates, or create a new one and submit it for approval - straight from the API.

GET/v1/templatesscope: templates:read

List your WhatsApp message templates: those of the WhatsApp Business Account of one number. Since v2.9.397 phone_number_id names it, one of your connected numbers (a number key: its own; another number of your account is 403 number_mismatch, and a number that is not one of your connected numbers is not used and warnings says so). Without it a number key lists the templates of its own number and an account-level key those of your primary number, as before (on an account with one number they are the same list). Up to 500 template-language entries (it was the first 100). By default only approved templates (the ones you can send); ?status=ALL shows pending and rejected too, and fresh=1 skips the two-minute cache (since v2.9.402, within 60 seconds of the last fresh read of the same list, that read is answered again, with served_from_cache: true and read_at, when it was read from WhatsApp, at the end of the answer). Each item includes name, language, category, status, the variable count (param_count), header_type, and buttons (index, type, text, url): a link button whose url ends in {{1}} is filled per message.

POST/v1/templatesscope: templates:write

Create a template and submit it to Meta for approval. Body: name (lowercase_underscore), language, category (UTILITY or MARKETING), body (use {{1}}, {{2}} for variables), optional examples (one sample value per variable, in order - improves approval), a header, footer (up to 60 characters), up to 3 buttons (QUICK_REPLY / URL / PHONE_NUMBER; each text up to 25 characters; a URL button takes url, a PHONE_NUMBER button takes phone_number), and allow_category_change (true or false; true when left out, anything else is 400 invalid_allow_category_change). The answer carries category (what you asked for), allow_category_change, and assigned_category: the category in Meta answer to the creation, or null when that answer named none. Approval usually takes minutes to a few hours - poll GET /v1/templates?status=ALL. This endpoint takes no Idempotency-Key: after a timeout, check GET /v1/templates?status=ALL&fresh=1 before retrying.

curl https://api.goultra.ai/v1/templates \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order_confirmed",
    "language": "en",
    "category": "UTILITY",
    "body": "Hi {{1}}, your order {{2}} is confirmed. We will update you when it ships.",
    "examples": ["Jordan", "#9182"],
    "footer": "Reply STOP to unsubscribe",
    "buttons": [ { "type": "URL", "text": "Track order", "url": "https://acme.com/my-orders" } ]
  }'

Media headers and link buttons

  • Header. header_type is TEXT (with header_text, fixed text up to 60 characters), IMAGE, VIDEO or DOCUMENT. For a media header Meta reviews a sample: give it as header_url, a public https link (JPEG or PNG up to 5 MB, MP4 up to 16 MB, PDF up to 16 MB) served with the content type of its kind (image/jpeg or image/png, video/mp4, application/pdf), or leave it out and a neutral placeholder is used. A sample that cannot be used (its address, type or size) is 400 header_sample_failed. The media each message shows is sent with that message.
  • Link button that changes per message. A URL button can end in {{1}}, for example https://certs.example.com/{{1}}. It needs an example: the full sample link (starting with the same address) or only the part that replaces {{1}}. A message fills it with buttons (see Messages). One variable per link, at the very end.
  • Nothing is dropped. A field the API cannot use is refused with its name: invalid_name, invalid_category, invalid_examples (one sample per body variable), invalid_header_type, invalid_header_text, invalid_header_url, header_not_supported, header_sample_failed, invalid_footer, invalid_button, invalid_button_example, button_example_required, too_many_buttons. A name Meta already holds in that language is name_exists, with the code, subcode and fbtrace_id from Meta. An unknown field is listed in warnings.
curl https://api.goultra.ai/v1/templates \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "warranty_certificate",
    "language": "en",
    "category": "UTILITY",
    "body": "Hi {{1}}, the warranty for your purchase is ready. Certificate number: {{2}}.",
    "examples": ["Jordan", "CERT-55120"],
    "header_type": "IMAGE",
    "header_url": "https://example.com/media/warranty-header.jpg",
    "buttons": [ { "type": "URL", "text": "View certificate",
                   "url": "https://certs.example.com/{{1}}",
                   "example": "https://certs.example.com/warranty/demo" } ]
  }'

Category matters. Use MARKETING for promotions (sale, discount, offer). Meta may approve a UTILITY template with promotional wording as MARKETING instead of rejecting it: check category in GET /v1/templates once it is approved.

Keeping a UTILITY category

"allow_category_change": false asks Meta not to move the template to another category. Under the rules Meta has applied since April 2025, Meta decides the category itself and can still change it, at review or later, also after approval: assigned_category shows what Meta answered at creation, and category in GET /v1/templates what it is now. Keep a security or account alert free of promotional wording, so it stays UTILITY.

curl https://api.goultra.ai/v1/templates \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "login_alert",
    "language": "en",
    "category": "UTILITY",
    "allow_category_change": false,
    "body": "New sign-in to your account from {{1}} near {{2}}. If this was not you, tap Activate emergency mode.",
    "examples": ["iPhone 15", "Lisbon"],
    "buttons": [
      { "type": "QUICK_REPLY", "text": "Activate emergency mode" },
      { "type": "QUICK_REPLY", "text": "It was not me" }
    ]
  }'

Response

{
  "request_id": "req_abc129",
  "name": "login_alert",
  "language": "en",
  "category": "UTILITY",
  "status": "PENDING",
  "header_type": "NONE",
  "allow_category_change": false,
  "assigned_category": "UTILITY",
  "message": "Template submitted to Meta for approval. Poll GET /v1/templates?status=ALL for the status."
}

Campaigns

Send one approved template to many recipients. The send is queued and paced automatically and passes every brake (template quality, number tier, opt-out, quota).

POST/v1/campaignsscope: campaigns:write

Submit a campaign. Takes an Idempotency-Key.

curl https://api.goultra.ai/v1/campaigns \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: spring-sale-2026-04-01" \
  -d '{
    "name": "Spring sale",
    "template_name": "spring_sale",
    "language": "en",
    "category": "MARKETING",
    "phone_number_id": "106540352242922",
    "recipients": [
      { "phone": "+14155550101", "name": "Jordan", "custom_data": { "code": "SPRING10" } },
      { "phone": "+14155550102", "name": "Sam", "custom_data": { "code": "SPRING15" } }
    ],
    "var_mappings": [ { "type": "name" }, { "type": "cf_code" } ]
  }'

Response

{
  "request_id": "req_abc127",
  "campaign_id": "camp_1782940000123_x7k2p9q4m",
  "status": "express",
  "queue_type": "express",
  "total": 2,
  "queued": 2,
  "opt_out_filtered": 0,
  "duplicate": false
}
  • template_name, language (always send it, the code exactly as GET /v1/templates lists it), name and recipients: up to 100,000, each a number such as "+14155551234" or an object { "phone", "name", "custom_data" }; other keys are ignored.
  • var_mappings: one entry per body variable, in order: { "type": "name" } (the recipient name, or Valued customer without one), { "type": "phone" }, { "type": "custom", "customValue": "Spring sale" } (the same text for everyone) or { "type": "cf_code" } (the recipient custom_data.code). Without it no body values are sent, so a template with variables fails for every recipient.
  • header_url: one https link shown to every recipient, for a template with an IMAGE or VIDEO header (a link ending in .mp4, .mov or .avi is sent as video, any other as image). DOCUMENT headers are not supported in campaigns, and nothing checks the link against the template before sending: a media template without it, or a link on a template without a media header, fails every message.
  • category: MARKETING (the default) or UTILITY. Send UTILITY for a utility template: as MARKETING, contacts without marketing consent are left out and marketing quiet hours apply.
  • phone_number_id: with an account-level key, required when the account has more than one number (400 phone_number_required). A number key sends from its own number when you leave it out (since v2.9.393; it was refused), and another number of your account is refused with 403 number_mismatch.
  • A campaign cannot fill a link button ending in {{1}}: use a template without one, or POST /v1/messages with buttons.
  • The same template to the same recipients within 120 seconds is not sent again: the answer is the first campaign, with duplicate: true, even with a new Idempotency-Key.
  • While an account has fewer than two completed campaigns, one of more than 500 recipients or more than half the daily limit is refused once with 400 first_large_campaign_review: confirm the list is opted in and submit it again with "first_large_review_ack": true.
  • Limits (since v2.9.402): a header_url over 2,048 characters or a customValue over 1,024 is 400 field_too_long, field naming it, because they go into every message. One recipient never stops a campaign: a name over 256 characters is shortened to 256 and sent, and a recipient whose phone is over 32 characters, or whose value for a variable (the custom_data field a cf_ entry names) is over 1,024, is left out while everyone else is sent. The answer then ends with field_limits: skipped_count, trimmed_count and the first 20 of each, with index, field (such as recipients[3].name), reason and phone. Only when every recipient is left out is the campaign 400 field_too_long. The rest of custom_data is not checked. A campaign is checked against your monthly quota and the WhatsApp tier of its number with the recipients still to go out of your campaigns that can still send counted too (since v2.9.404 those of a scheduled campaign too, toward the monthly quota from the moment it is created; the 24-hour tier counts only what is already queued to go out within the next 24 hours, and a scheduled campaign is checked against it when it starts), and a refusal says how many, in its message and in unsent_counted; the recipients of a completed or cancelled campaign never count. An account sends at most 2,000 recipients a minute through the express queue: a campaign beyond it, or one with a recipient over 16,384 characters as JSON, goes to the paced queue, answered with queue_type bulk, and is never dropped.

In the answer, total is the recipients after duplicates and opt-outs were removed (opt_out_filtered says how many opted out), queued is those queued at once, and status and queue_type are express or bulk (status is duplicate for a repeat). Refusals: 400 bad_request (a missing field, or more than 100,000 recipients), 400 phone_number_required, 400 first_large_campaign_review, the plan limit (limit_exceeded), a template or number that cannot send now (template_not_sendable, template_quality_blocked, meta_tier_limit_exceeded, phone_quality_red, phone_quality_unverified, portfolio_unresolved: read the message), marketing_disabled, and 503 campaign_partially_queued (do not submit it again).

GET/v1/campaigns/:idscope: campaigns:read

Campaign progress: totals plus sent, delivered, read, failed and pending. delivered and read are part of sent, and pending counts only messages in flight, so right after submitting the queued messages are in no count yet. The campaign is done when status is completed, which also fires campaign.completed.

Contacts

Contacts are account-level (shared across all your numbers). A phone is read as its digits, 10 to 15 of them with the country code, so 4155551234 is saved as +4155551234: always include the country code (+14155551234).

GET/v1/contactsscope: contacts:read

List contacts, newest first. Query: search (phone or name), limit (max 100), offset.

POST/v1/contactsscope: contacts:write

Create a contact, or update it when the phone already exists. Body: phone, optional name, email. Answers 201 with created: true for a new contact, 200 with created: false (name and email updated) for an existing one, and 402 contact_limit_exceeded at the plan limit.

curl https://api.goultra.ai/v1/contacts \
  -H "Authorization: Bearer $GOULTRA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone": "+14155551234", "name": "Jordan Lee" }'
GET/v1/contacts/:idscope: contacts:read

Fetch one contact.

PATCH/v1/contacts/:idscope: contacts:write

Update name / email, or opt a contact out with {"opt_in": false} (stored on the contact: see Compliance). Sending {"opt_in": true} returns 403 optin_recipient_only.

DELETE/v1/contacts/:idscope: contacts:write

Delete a contact. An opt-out set through the API goes with it.

Bookings

List and create appointments on the GoUltra calendar. Pass phone_number_id for the number an appointment belongs to. Without it, with an account-level key, the list covers all your numbers and a new appointment goes to your primary number; with a number key, the list leaves out the appointments of your other numbers and a new appointment goes to the number of the key. Times are ISO 8601 with Z or an offset (2026-10-12T13:00:00Z); a time without one is read as UTC, not your business time zone. Answers are in UTC.

GET/v1/bookingsscope: bookings:read

List appointments. Query: status (exactly one of booked, confirmed, cancelled, cancelled_by_user, rescheduled), time_filter (upcoming / past / today), date_from and date_to (YYYY-MM-DD, inclusive, UTC; they work only together, and then replace time_filter), page (at most 1,000; since v2.9.397 a larger one is 400 invalid_page: narrow the list with date_from and date_to, time_filter or status instead), limit (default 50, max 200; since v2.9.397 one below 1 is the default too), phone_number_id.

GET/v1/bookings/:idscope: bookings:read

Get one appointment, including the assigned staff/provider.

POST/v1/bookingsscope: bookings:write

Create a manual appointment. Body: start_time, end_time (required), optional customer_name (at most 200 characters since v2.9.402, 400 field_too_long), phone_number (the customer number the reminder goes to, with its country code, 10 to 15 digits; spaces, dashes, dots, parentheses and one leading + may be in it; since v2.9.397 one WhatsApp cannot be sent to is 400 invalid_phone), service_name (at most 200 characters since v2.9.402), duration (the minutes a booking-mode number holds, 1 to 1,440, default 30; outside it 400 invalid_duration since v2.9.402), resource_id, notes (at most 2,000 characters, 400 field_too_long), phone_number_id (one of your connected numbers, otherwise 400 invalid_phone_number_id). Answers { "booking_id", "status": "created", "gcal_synced", "gcal_event_id" }. 409 time_conflict when the time overlaps any appointment on your account, on any number or staff member, including timed events synced from Google Calendar. A missing or unreadable time is 400 missing_field or 400 invalid_field. Takes an Idempotency-Key.

Quotes

GET/v1/quotesscope: quotes:read

List the active price-list / quote menus of one WhatsApp number: phone_number_id; without it, the number of a number key, or your primary number with an account-level key. Each item is a named quote (a built price list or an uploaded PDF).

Quotes are delivered to a customer inside a live WhatsApp conversation from the GoUltra product. Sending a cold quote over the API is on the roadmap.

Account

GET/v1/accountscope: account:read

Your account + plan summary: name, whether sending is active, connected-numbers count, and plan limits (messages/month, messages/day, contacts). A limit of -1 or null means unlimited.

GET/v1/account/numbersscope: account:read

List your connected WhatsApp numbers with their phone_number_id, quality rating, messaging tier, and per-number flags (coexistence, booking mode, quotes). A number key returns only its own number.

Usage

GET/v1/usagescope: usage:read

This calendar month (UTC): messages (monthly_used, monthly_cap, monthly_remaining, and api_used, the messages sent with POST /v1/messages), auth_messages for one-time codes, and contacts (used, cap). A cap of -1 means unlimited, and monthly_remaining is then -1 too.

Changelog

What changed in the API, newest first. Every change is additive: nothing an integration already uses was renamed, moved or removed. How long data is kept is a decision about the data, not about the API, and its entry says it: since v2.9.400 a webhook delivery is kept at least 30 days. Since v2.9.403 so are a verification and the log of OTP sends.

v2.9.405, 21 October 2026: the limits of your plan hold when requests arrive at once

  • Webhooks: turning an endpoint back on (PATCH /v1/webhooks/:id with status active) when 10 are already active is 400 too_many_endpoints, and the endpoint stays as it was. Endpoints registered at once never pass 10. See Webhooks.
  • Templates: templates created at once never pass the number your plan allows; a create past it is answered as before, 402 template_limit_exceeded, before anything is sent to Meta.
  • Nothing else changed: sending a message, reading its status, listing templates and every signature answer as before.

v2.9.404, 21 October 2026: every send is held to the plan

  • Campaigns: a refusal of POST /v1/campaigns for the plan or the WhatsApp tier ends with unsent_counted, the recipients of earlier campaigns still to go out that were counted with it (it was said only in the message, which is unchanged), and a field_too_long for which every recipient was left out ends with field_limits. The recipients of a scheduled campaign count toward the monthly quota from the moment it is created; the 24-hour WhatsApp tier counts only what is already queued to go out within the next 24 hours. See Campaigns.
  • Marketing pauses: while marketing is paused for an account, POST /v1/campaigns reads the category of the template at Meta: a MARKETING template is refused with 403 marketing_disabled and template_category whatever category the request sends, and a UTILITY or AUTHENTICATION one is not. See Marketing pauses.
  • Nothing else changed: sending a message, reading its status and listing templates answer as before.

v2.9.403, 21 October 2026: verifications and the OTP call log keep 30 days

  • A verification is kept at least 30 days: a verification created more than 30 days ago whose code can no longer be checked (approved, failed, expired or canceled, or past its expiry with no lock running) is deleted, normally within a day of passing 30 days and never sooner. Reading, checking, resending or cancelling it then answers 404 verification_not_found, as an id that never existed, and an Idempotency-Key it was started with is remembered as long as it is kept: the same key used again after that starts a new verification. Until now every verification was kept as long as the account.
  • The log of OTP sends is kept at least 30 days, and a call of the current month (UTC) until the month is over, so the count of OTP calls this month on your Dashboard stays complete; the recent calls it lists reach back at least 30 days. Until now every call was kept as long as the account.
  • Nothing else changed: every answer, header and signature is as before, and the webhook delivery log keeps its 30 days of v2.9.400.

v2.9.402, 21 October 2026: limits that hold, and a body each endpoint can use

  • Request size: a body larger than its endpoint takes is refused with 413 payload_too_large and max_bytes, before anything is read or done: 32 MB for POST /v1/campaigns, 1 MB for POST /v1/messages and POST /v1/templates, 128 KB for POST /v1/inbox/send, 64 KB for any other endpoint that takes a body. Except for campaigns, each leaves room for every text of its endpoint at its limit, written in the longest JSON form; the 32 MB of a campaign is about 335 bytes for each of 100,000 recipients: when your recipients carry more (a long name, contact fields), send fewer of them in each call (10,000 a call leave about 3,350 bytes each). See Rate limits.
  • Bookings: customer_name and service_name take at most 200 characters (400 field_too_long), and duration, when given, 1 to 1,440 minutes (400 invalid_duration). See Bookings.
  • Campaigns: the fields that go into every message have limits; the header link and a custom value refuse the campaign (400 field_too_long, field names the one), while the field of one recipient never does (a name is shortened, a recipient with a phone or a value too long is left out, and field_limits says which). The recipients still to go out of your campaigns that can still send count toward the monthly quota and the WhatsApp tier when a new campaign is checked, and an account sends at most 2,000 recipients a minute through the express queue: a campaign beyond it goes to the paced queue (queue_type bulk), never dropped. See Campaigns.
  • The hourly limit of the account (3,000 accepted requests) counts every accepted request, from any number of keys: it rose about once a second, whatever the rate. Every service an account key or number key reaches counts toward it and is held by the suspension of the account. See Rate limits.
  • Templates: fresh=1 within 60 seconds of the last fresh read of the same list answers that read, with served_from_cache and read_at at the end of the answer. See Templates.
  • Nothing else changed: sending a message, reading its status and listing templates answer as before.

v2.9.401, 21 October 2026: security fixes

  • A number sends only for its own account: every send looks its WhatsApp number up together with the account it sends for, and nothing is sent from a number of another account.
  • Test keys are read-only in the store plugins too: a gk_test_ key is refused with 403 test_key_read_only on every WooCommerce and Shopify plugin route that sends a message or changes something, as on /v1 since v2.9.393. Reads stay open, the connect check of the plugin among them, which records nothing for a test key. A store uses a live key: Test keys.
  • API keys: the Dashboard session of a team member can no longer read, change or rotate the API key of a number, or rotate the account key.
  • Usage: a message that is sent is no longer left out of your usage and your plan because a field that describes it is too large. The answer to your request is unchanged.
  • Nothing else changed in the API described here: every other answer, every header and every signature is as before.

v2.9.400, 21 October 2026: the delivery log keeps 30 days

  • A delivery is kept at least 30 days: a delivery whose event was created more than 30 days ago is deleted once it has finished (delivered, dlq or cancelled), normally within a day of passing 30 days and never sooner; one still pending or sending is kept until it finishes. Until now every delivery was kept as long as the account. The delivery log and the redelivery routes reach back at least 30 days, and a deleted delivery answers 404 not_found, as an id that never existed: Delivery log and redelivery.
  • A web page can send PATCH: the CORS preflight of a /v1 path now allows PATCH, after the methods it allowed before, so PATCH /v1/contacts/:id and PATCH /v1/webhooks/:id can be called from a browser: What every answer carries.
  • A client timeout of at least 60 seconds for POST /v1/messages, not 30: when WhatsApp answers slowly, the answer can take about 25 seconds plus our database reads. The Python and PHP samples now wait 60 seconds: Idempotency.
  • The message of a 500 server_error no longer says a retry is always safe: retry a read, and before you send a write again, list or GET to see whether it was done. The code and the status are as before, and the error catalog says when to send again.
  • Nothing else changed: every other answer, header and signature is as before.

v2.9.399, 20 October 2026: an event is not lost to a passing database error, and the next one does not wait for the minute

  • Back to back: when the first attempt of an event is answered with a 2xx, the next delivery to the same endpoint that fell due in the last two minutes is sent right after it, the one due the longest first, up to 5 in a row, and such a run lasts about 20 seconds at most. It used to wait for the sweep, up to a minute and longer. Two taps a second apart now usually arrive one after the other, when the one before is answered with a 2xx. One delivery is still in flight per endpoint, an endpoint that fails gets no extra attempt, and the sweep sends whatever is left: how deliveries are sent.
  • An event survives a passing database error: when the delivery of an event could not be stored because of an error that passes, it is stored again, at most twice and within about a quarter of a second, with the same id, so it is never stored twice. And when the delivery to one of your endpoints cannot be stored, your other endpoints still get the event.
  • Nothing else changed: the signatures, the retry schedule, every answer and every header are as before.

v2.9.398, 20 October 2026: documentation and developer experience

  • The contract, machine-readable: GET /v1/openapi.json (OpenAPI 3.1, with examples answered by the code, the scopes and the error codes of each operation, and the webhook payloads), GET /v1/postman.json (a Postman collection) and GET /v1/errors (the error catalog). No key needed. See The contract, machine-readable.
  • Every answer names itself: X-Request-Id, the request_id of its body, and GoUltra-Version, the dated revision of the API (2026-10-20), both readable by a web page. X-API-Version stays 1.0.0. On every answer of the /v1 router; the CORS preflight is answered before it. See What every answer carries.
  • The CORS preflight of a /v1 path also allows the Idempotency-Key header, so a web page can send it.
  • Examples you can send: every request that takes a body has an example in the OpenAPI document and in the Postman collection, the request the code answered.
  • Retrying after a failure: an Idempotency-Key covers only a request that succeeded. After a 502 send_failed with meta_error_code timeout or network the message may have been delivered, and after a 500 that can follow a create the record may exist: check before sending it again. See the error catalog.
  • What happened to a message: a read, and a failure after WhatsApp accepted a message, are recorded now. GET /v1/messages/:id answers them in latest_status (read, failed) and error ({ "code", "title" } as WhatsApp gave them); status keeps its two values, and the daily quota and GET /v1/usage count every message as before. A message read or failed before v2.9.398 answers delivered or sent there. See Messages.
  • These docs, organised in the order you build an integration, with Quickstart in curl, Node.js, Python and PHP, the error catalog, Pagination, Events and payloads, Media, Testing, a Reference of every route and Support; the signature can also be checked by hand with bash and openssl.
  • Nothing else changed: every other answer has the same status and body as before.

v2.9.397, 20 October 2026: answers that are true, inputs that are safe

  • For an integration that already sends: POST /v1/messages, GET /v1/messages/:id and GET /v1/templates answer as before, with fields added after the others; POST /v1/messages sends every number and value exactly as before, and refuses only a MARKETING template while marketing is paused for the account.
  • What happened to a message: GET /v1/messages/:id adds latest_status and error; status keeps its two values. Today latest_status answers sent or delivered; read and failed, with error, follow once they are recorded, and this changelog will say when. message.failed adds error_code and error_title beside error. See Messages.
  • Files: GET /v1/media/:key downloads the file an inbox message carried, and inbound.message adds media ({ "url", "mime_type" }) after its other fields; media_url is unchanged. See Files a message carried.
  • Templates: POST /v1/messages answers the template_category Meta holds, last, and a template whose category at Meta is MARKETING is refused with 403 marketing_paused while marketing is paused for the account. GET /v1/templates takes phone_number_id, gives a number key the templates of its own number, and lists up to 500 entries (it was 100).
  • Numbers: inbox replies and bookings read a number written with spaces, dashes, dots, parentheses or as a JSON number, and refuse one WhatsApp cannot be sent to with 400 invalid_phone, saying why. On POST /v1/messages a number is read exactly as before, for now.
  • Inputs: an inbox reply over 4,096 characters and booking notes over 2,000 are 400 field_too_long; a path that is not valid percent-encoding is 400 invalid_path (it was a 500); on GET /v1/bookings a limit below 1 is the default and a page above 1,000 is 400 invalid_page; webhook registration and update add ignored_events and, when no name was valid, warnings.

v2.9.396, 20 October 2026: the webhook health check and the secret sealing, working

  • The hourly check of failing endpoints and the daily sealing of existing signing secrets now reach your data. On v2.9.394 and v2.9.395 both stopped at the first endpoint they reached, so no failing-endpoint email was sent, no endpoint was turned off and no existing secret was sealed in that time. Nothing was done in error: every delivery was signed and sent as before. See When an endpoint keeps failing.

v2.9.395, 20 October 2026: limits you can read, sends that never double

  • Where your key stands, on every answer: X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. See Rate limits.
  • Retry-After on every 429, the same seconds as retry_after, and on 503 redeliver_unavailable (nothing was sent again, so a retry is safe; of the 503 error codes, the only one). 429 api_suspended says the time left in the suspension when less than an hour is left, and never more than 3600 (it always said 3600): the suspension may last longer, so check again then. The sending burst limit names its wait (retry_after), and the number throughput adds retry_after beside retry_after_ms.
  • A refused request is not counted toward the 3,000 an hour: one refused by the per-key limit or by a test-key rule counted there until now, and counts toward a separate 30,000 an hour instead. The per-key minute is counted in one step, so two requests at the same moment can no longer both take its last place.
  • Idempotent-Replayed: true on every replay. The key is held from the moment the first request starts, and a second request with it, or a key used again with a different body, is recognised; both are answered as before for now. The codes they will get once the stricter check is on are documented: 409 idempotency_in_progress and 422 idempotency_key_reused. See Idempotency.
  • Two inbox replies with one key at the same moment: the second now answers the reply that was queued (it was 500 send_failed).
  • Webhook signing secrets are stored encrypted, the ones made before within a day. Nothing changes for you: a secret is still shown once, when it is made or rotated, and every delivery is signed exactly as before (Rotating the secret).

v2.9.394, 20 October 2026: webhooks you can rely on

  • A delivery log: GET /v1/webhooks/:id/deliveries, newest first and paged, and GET /v1/webhooks/:id/deliveries/:delivery_id with its payload. See Delivery log and redelivery.
  • Redelivery: one delivery, or what failed or was cancelled since a time (up to 500 in a request, paced 5 a minute across GoUltra, one bulk of an account at a time and at most 24 hours ahead), sent again with the same id, so a server that de-duplicates on id handles each event once.
  • An endpoint that keeps failing: the owner of the account is emailed once the hourly check has seen it failing for an hour; an endpoint registered from 20 October 2026 that fails for 72 hours without a break is turned off, and the endpoint object adds disabled_reason. Endpoints registered before are never turned off. See When an endpoint keeps failing.
  • Retries are sent as before: the ones due the longest first, up to 15 a minute across GoUltra, one at a time per endpoint (Delivery and retries).
  • Rotation with an overlap, only when you ask for it: keep_previous_for_seconds on POST /v1/webhooks/:id/secret. While it lasts, X-GoUltra-Signature-256 carries two v1 values, the current one last, and the verification samples accept either. Without the field a rotation works as before. See Rotating the secret.
  • New error codes: delivery_in_progress, endpoint_disabled, invalid_event_type, invalid_keep_previous, invalid_limit, invalid_since, invalid_status, redeliver_rate_limited, the one 429 that also carries a Retry-After header (until v2.9.395), and redeliver_unavailable, a 503 (Errors).

v2.9.393, 19 October 2026: number keys stay on their number

  • A number key acts on its own number only, on every endpoint: another number of your account, named in a request or owning what it names, is refused with 403 number_mismatch, and a request that names no number uses the number of the key, campaigns and bookings included. An account with one number sees no difference. See Per-number keys.
  • A webhook endpoint belongs to a number or to the account. An endpoint registered with a number key receives the events of its number and contact.created; the endpoint object adds phone_number_id. Endpoints registered before this release keep receiving every event, and number keys keep managing them. See Endpoints and numbers.
  • Test keys are read-only: a call that would change your account answers 403 test_key_read_only; a test event to your webhook endpoint is still allowed. See Test keys.
  • Verification webhooks carry X-GoUltra-Timestamp and X-GoUltra-Signature-256 too, and are never sent without a signing secret.
  • New error codes: number_mismatch, test_key_read_only and number_check_unavailable. New in these docs: Keys and scopes, with least privilege, test keys and rotation.

v2.9.392, 19 October 2026: one-time action buttons

  • Quick-reply payloads per message: buttons: [{ "index", "payload" }] on POST /v1/messages (Quick-reply buttons), refused with invalid_button_payload or button_not_quick_reply when they cannot be sent.
  • The tap on inbound.message: wa_timestamp, context, button and is_button, after the fields it always had (A tap on a button).
  • Delivery: the first attempt is made at once, one delivery is in flight per endpoint, and disabling or deleting an endpoint cancels its queue (cancelled_deliveries).
  • Signature v2: X-GoUltra-Timestamp and X-GoUltra-Signature-256 beside the unchanged X-GoUltra-Signature (Verify the signature).
  • Test events: a sample tap or text, a resend and a delayed delivery, at most 20 an hour (429 test_rate_limited; Test events).
  • Templates: allow_category_change on POST /v1/templates, and assigned_category in its answer (Keeping a UTILITY category).

Support

  • Ask from your Dashboard: message support from your GoUltra Dashboard.
  • Say which answer: the request_id of the answer (the X-Request-Id header carries the same), the time, the route, and the GoUltra-Version header. For a webhook, the delivery id (X-GoUltra-Delivery); the delivery log shows the status code and the error of each attempt.
  • Never send a key or a secret: the request_id identifies an answer without your API key or your webhook signing secret. If one was exposed, rotate it (Rotation).
  • What changed: the Changelog, newest first. A change is additive: nothing an integration uses is renamed, moved or removed.

GoUltra Systems LLC - Dashboard - API index (JSON) - goultra.ai

Questions? Message support from your GoUltra Dashboard. Quote the request_id from any response.