One API key per agent opens every surface: talk to the agent from your software, keep your CRM, booking or ordering system in sync, send WhatsApp templates, and get a signed webhook the moment anything happens — whether it happened in a chat, in the dashboard or through the API.
Base URLhttps://everysecond.ai/api/v1
AuthAuthorization: Bearer es_…
Coverage70 endpoints · 55 webhook events
Get a keyDashboard → Integrations → API key (one per agent).
Call the APIEvery request sends Authorization: Bearer <key> (or X-Api-Key).
Subscribe to webhooksRegister an endpoint, verify the signature, and stay in sync both ways.
The EverySecond platform API is REST over HTTPS, JSON in and JSON out. Base URL for every endpoint under /api/v1: https://everysecond.ai/api/v1.
Every request carries one agent’s API key — es_ followed by 32 characters — either as Authorization: Bearer es_… or as X-Api-Key: es_…. The account owner generates, rotates and revokes keys on the Integration screen of the dashboard (sidebar → Integration → the agent).
One key is one agent. No endpoint takes an agent id: the key decides which agent a request is about, and a key can never read or write another agent’s data — another agent’s chat, lead, connection request or webhook id answers 404, never 403.
Every answer is JSON, whatever Accept header the request sends — errors included (validation failures, unknown paths, wrong methods).
Rate limit: 120 requests per minute per key. Every response carries X-RateLimit-Limit and X-RateLimit-Remaining — errors and 429s included; a 429 adds Retry-After (seconds) and X-RateLimit-Reset (unix time).
Send the key in either header; when both are present the Bearer token wins. A missing key answers 401 with Missing API key. Send it as "Authorization: Bearer <key>" or "X-Api-Key".; a key that matches no agent answers 401 with Invalid API key.
Treat the key as a password: it reads your customers’ names, phone numbers and conversations. Keep it on your server — never in a browser, a mobile app or a public repository. Rotating it on the Integration screen invalidates the old key at once.
The website widget (/embed/{api_key}/…) is the one surface designed to carry the key in a browser: those three URLs can only chat with the agent, never read leads, transcripts or settings.
Every /api/v1 answer is JSON whatever Accept header you send, so the header is optional (sending Accept: application/json, as the examples on these pages do, is harmless). A failed validation is always a 422 body, never a redirect.
A 401 counts against the calling IP address, not a key: 60 requests per minute with a missing or unknown key. The 401 carries that allowance in X-RateLimit-Limit: 60 / X-RateLimit-Remaining; once an address spends it, further attempts with a missing or wrong key get 429 for the rest of the minute. A valid key is always served, whichever address it comes from (integrations on shared IPs such as Zapier or Make are never locked out by someone else’s broken key). A working integration never fails authentication and never touches this allowance.
BASH
curl https://everysecond.ai/api/v1/agent \
-H "Authorization: Bearer $EVERYSECOND_KEY"
# or, when a Bearer header is awkward in your stack:
curl https://everysecond.ai/api/v1/agent \
-H "X-Api-Key: $EVERYSECOND_KEY"
Every error is a JSON object with an error message, whatever Accept header the request sent. Many add a machine-readable code (validation_failed, rate_limited, the chat API’s plan_ended, quota_reached, invalid_audio) or a details list (webhook endpoint validation).
Requests that fail the field rules of an endpoint (types, lengths, enums) answer 422 with error, code: "validation_failed", and the framework’s message (the same text as error) plus an errors object keyed by field. The wording follows the platform’s language (Arabic by default), so branch on code and the field names, never on the text. The webhook endpoints keep their own shape: { "error": "Validation failed.", "details": [ … ] }.
Unknown paths (404), a wrong method (405) and unexpected failures (500) are JSON too: error plus the same text in message.
Status codes: 200 done · 201 created · 401 missing or unknown key · 403 the plan does not allow it (taking an agent live, an ended subscription, a used-up message quota) · 404 no such record on this agent · 409 already in that state (a channel that is already connected, a basket on an agent that takes no orders) · 422 validation · 429 rate limited · 502 / 503 a service behind the API (the knowledge index, the agent’s model) failed — safe to retry.
API error
{
"error": "No such chat on this agent."
}
API error with a code
{
"error": "Message quota reached (5000). Upgrade the plan or wait for the next cycle.",
"code": "quota_reached"
}
Validation error (422)
{
"error": "The message field must not be greater than 4000 characters.",
"code": "validation_failed",
"message": "The message field must not be greater than 4000 characters.",
"errors": {
"message": [
"The message field must not be greater than 4000 characters."
]
}
}
120 requests per minute per key, counted across every /api/v1 endpoint the key calls. The window opens with the first counted request and resets 60 seconds later.
Once the key is recognised, every response carries X-RateLimit-Limit: 120 and X-RateLimit-Remaining — successes, the endpoint’s own 4xx answers and 429s alike. Over the limit the answer is 429 with code: "rate_limited", Retry-After (seconds), X-RateLimit-Reset (the unix time the window resets) and X-RateLimit-Remaining: 0.
401 answers have no key to count against: they carry the calling IP address’s failed-authentication allowance instead (60 per minute, see “Authenticating requests”), and past it further failed attempts from that address get 429 with the same headers for the rest of the minute (a valid key from it is still served).
The website widget has its own, separate limits: 30 messages per minute per visitor (its visitor_id, else the IP), a ceiling of 600 per minute across the key, and 10 voice notes per minute per visitor IP — each 429 with Retry-After.
TEXT
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 37
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1791371245
{"error":"Rate limit exceeded (120 requests/minute). Retry in 37s.","code":"rate_limited"}
Timestamps are ISO-8601 in UTC (2026-10-07T11:02:11+00:00). In a query string + means space, so URL-encode offsets (%2B02:00); the leads feed repairs an unencoded + in since / until, other endpoints do not.
Ids are integers, except knowledge document ids, which are strings (api_…, pdf_…, url_…). Webhook event ids are UUIDs.
Strings are trimmed and empty strings arrive as null, so sending "" for a nullable field clears it. Update endpoints are partial: only the keys you send are touched.
Money in the chat reply, the basket endpoints and webhook bodies keeps its decimal point (325.0), so typed receivers can bind every amount to a float.
An API key always acts on the agent’s live data. The Training Yard (the dashboard’s practice chat and its test-mode toggle) is a separate data set: its chats, leads and orders never appear in API responses, and nothing that happens there fires a webhook — a practice lead never reaches your CRM.
Conversations held through the chat API are production conversations: every customer message counts toward the plan’s message quota, and they create leads and fire lead.* webhooks exactly like WhatsApp or Messenger customers. When the subscription has ended or the quota is used up, the chat API answers 403 (plan_ended / quota_reached) and the website widget answers with a polite holding line instead of an AI reply.
Talk to the agent from your own app, backend, kiosk or mobile client. A chat is one customer: open it once, keep its id, send every message of that customer through it, and read the transcript back whenever you like.
Behind the id the agent runs the same production pipeline a WhatsApp or Messenger customer gets — memory across turns, lead capture and scoring, flags, orders, reservations, tickets and every lead.* webhook. API chats appear on the Tracking screen (with a globe badge) and on the Leads screen as channel website, and a teammate can take a chat over there.
Every customer message counts toward the plan’s message quota. The chat API is not gated by the widget toggle or by the Orders module; the basket endpoints need an agent that takes orders.
Open a durable conversation for one customer, or get back the one already open for your external_id.
Idempotent on external_id: it is unique per agent, so creating again with the same value returns the existing chat with 200 and created: false instead of a second one. On that call any non-null name, phone or metadata you send overwrites the stored value (metadata is replaced as a whole, not merged).
Without external_id every call opens a new chat (201). Keep the returned chat.id per customer.
Opening a chat sends nothing to the agent and creates no lead yet; the lead appears with the customer’s first message.
metadata is yours — it is echoed back on every read and is not shown to the agent. name is how the agent addresses the customer and how the chat is labelled on the Tracking screen.
Parameter
In
Type
Description
external_id
body
string
Your own id for this customer (user id, session id, phone). Up to 191 characters. Recommended: it makes the call idempotent.
name
body
string
Customer name, up to 120 characters.
phone
body
string
Customer phone, up to 40 characters. Stored on the chat; the agent still captures the phone the customer says onto the lead.
metadata
body
object
Any JSON object you want echoed back (plan, page, locale…).
This agent’s chats, most recently active first, paginated.
Ordered by last_message_at (newest first), then by id. Chats that have not received a message yet have last_message_at: null.
The website widget of an agent that takes orders runs on the chat API, so its visitors appear here too, with external_idwidget:<visitor_id> and metadata{ "source": "widget" }.
Parameter
In
Type
Description
per_page
query
integer
1–100, default 25.
page
query
integer
Page number, from 1.
external_id
query
string
Only the chat with this external id (up to 191 characters).
Remove the chat handle; the transcript and the lead stay.
The transcript and the lead are the customer’s record and are kept (Tracking, Leads, GET /api/v1/leads). Only the handle goes, so a later POST /api/v1/chats with the same external_id starts a fresh conversation with no memory of the old one.
An open basket of that customer is not closed by this call — use DELETE /api/v1/chats/{id}/cart first if that is what you mean.
Send the customer’s message (text or a voice note) and get the agent’s reply in the same response.
Synchronous: the call returns when the agent has answered — usually a few seconds; allow a 60-second client timeout. Send one message at a time per chat; the agent reads them in order.
status says what happened: replied (the agent answered — reply holds the text) · handoff (a teammate has taken this conversation over on the Tracking screen; the AI stays silent, poll the transcript for their replies) · held (the message was routed to a human ticket, e.g. it followed a photo) · media_ticket (an unreadable voice note was handed to a human) · no_answer / silent (the agent chose not to answer) · error (the agent could not process it; a ticket was opened for the team and the HTTP status is 502). Only replied carries a reply; every other status has reply, reply_id and sent_at set to null.
message_id is the customer’s row in the transcript, reply_id the agent’s. lead_id is present once the conversation has produced a lead (it is left out of a 502).
products lists the product cards this reply recommends (up to 5, built from the menu — never from the model), or []. It is filled only for agents with the Orders module and the “product cards” photo setting.
Agents that take orders also get cart (the customer’s open basket after this turn, null when there is none — including right after it became an order) and order (the full order this turn placed, changed or cancelled, the same object as GET /api/v1/orders/{id}; otherwise null). Agents without orders get neither key. image (name, url) appears only when the agent sent a picture.
Voice notes: send multipart/form-data with an audio file instead of message. The note is checked by content (ogg/opus, m4a/aac, mp3, wav, webm), kept on the transcript, transcribed and answered like typed text; the response adds transcript and audio. When nothing intelligible can be made out, status is media_ticket, transcript and reply are null, and a ticket is opened for your team; the transcript row reads [audio].
Side effects: the message counts toward the plan’s message quota; the conversation creates and updates the lead and fires lead.created, lead.updated and lead.flagged; ordering agents fire the cart.* and order.* events; reservations and tickets behave as on any channel.
Parameter
In
Type
Description
idrequired
path
integer
The chat id.
message
body
string
The customer’s text, up to 4000 characters. Required unless audio is sent; cannot be combined with audio.
audio
body
file
A voice recording (multipart), up to 25 MB: ogg/opus, m4a/aac, mp3, wav or webm. Required unless message is sent.
language
body
string
Optional language hint for the transcription (ar, en…), up to 10 characters. Leave it out and the language is detected.
Both sides of the conversation, oldest first, with cursors to page back or poll forward.
Without a cursor you get the latest page. before (a message id) returns the page of messages older than it — pass the first id of the page you have to walk back. after returns only messages newer than it — poll with the last id you have seen.
has_more says there are more messages beyond this page in the direction you are reading; total is the number of messages in the whole chat.
author is customer for inbound rows, agent for the AI’s replies and human for a reply a teammate typed on the Tracking screen — which is how your app picks up a human takeover (status: "handoff" on send).
media is null or a list: a customer voice note (type: "audio", with url, mime, size; the transcript is in text), a picture the agent sent (type: "image"), or the product cards of a reply (type: "products", with items).
Parameter
In
Type
Description
idrequired
path
integer
The chat id.
limit
query
integer
1–500 messages, default 100.
before
query
integer
A message id: only messages older than it.
after
query
integer
A message id (0 or more): only messages newer than it.
BASH
# Poll for anything new (e.g. a teammate's reply) after the last id you have:
curl "https://everysecond.ai/api/v1/chats/17/messages?after=51214" \
-H "Authorization: Bearer $EVERYSECOND_KEY" \
-H "Accept: application/json"
The same basket object as cart in the send reply, GET /api/v1/orders/carts and the cart.* webhooks. null when there is no open basket — after it became an order or was closed, and always on an agent that takes no orders (this read does not answer 409).
Write the basket from your own store’s cart: the lines you send replace the basket’s lines.
Send the whole basket every time — items replaces the lines; "items": [] empties the open basket (it stays open with no lines). The same item listed twice counts once, with the quantities added up.
Each line names exactly one of external_id (your SKU, as in the menu API), menu_item_id or bundle_id, plus qty. Lines are priced from the menu (offers applied) and follow the chat’s ordering rules exactly as if the agent had added them.
Nothing is dropped silently. A line that cannot go in is listed under skipped with a reason: no_item (the line named no item), not_found (no such item on the branch), unavailable (not sellable now, or an unknown bundle) or not_orderable_in_chat. A quantity capped by the item’s per-order maximum is listed under adjusted with requested_qty, the qty used and reason: "max_qty_per_order". If none of the lines can go in, the answer is 422 with skipped and the basket is left as it was.
Branch: the one in branch_id, else the open basket’s branch, else the agent’s ordering branch, else the agent’s only branch. An agent with several branches and no open basket needs branch_id.
Side effects: when the lines actually changed, cart.item_added, cart.item_removed (as applicable) and cart.updated fire with source: "api", the cart’s changed_by becomes api, and the store’s last quote (merchant_quote) is cleared. The agent sees the basket as it is now on its next reply and will not put back an item your system removed unless the customer names that product again.
Parameter
In
Type
Description
idrequired
path
integer
The chat id.
itemsrequired
body
array<object>
The basket’s lines, up to 100. Must be present; may be empty.
items[].external_id
body
string
Your product id (up to 191 characters).
items[].menu_item_id
body
integer
EverySecond menu item id.
items[].bundle_id
body
integer
A bundle offer id.
items[].qtyrequired
body
integer
1–999.
branch_id
body
integer
The branch the basket belongs to; needed only when the agent sells from more than one branch and the chat has no open basket.
Close the open basket without an order — e.g. the customer paid for it on your website.
The basket gets status: "closed", closed_at, closed_reason and changed_by: "api", and cart.closed fires (not cart.converted — a closed basket is not an order). The agent is told it is done and will not offer it again.
Idempotent: with no open basket the answer is 200 with closed: false and cart: null. If the customer starts shopping again — in the chat or through another PUT — a new basket opens with a new id.
Parameter
In
Type
Description
idrequired
path
integer
The chat id.
reason
body
string
Why it closed, e.g. paid_on_website. Up to 100 characters; also accepted as ?reason=.
Open one chat per user with your own user id as external_id — the call is idempotent, so it is safe on every login or page load. Send each message the user types and render reply (and products, when present).
When the screen reopens, load the transcript, then poll GET /api/v1/chats/{id}/messages?after=<last id> every few seconds while it is open: a reply typed by a teammate on the Tracking screen arrives there with author: "human".
TEXT
POST /api/v1/chats { "external_id": "user-42", "name": "Mona Adel" } -> chat.id = 17
POST /api/v1/chats/17/messages { "message": "..." } -> reply
GET /api/v1/chats/17/messages -> the latest page
GET /api/v1/chats/17/messages?after=51214 -> anything new
The drop-in chat bubble for any website. One script tag adds a floating button that opens the agent in a popup, with voice notes and — for agents that take orders — product cards, a basket and real orders.
The key travels in the URL. That is by design and safe: these three URLs can only chat with the agent; they cannot read leads, transcripts or settings. The widget must be enabled on the agent screen, otherwise every call is refused.
Need history, the lead, or to resume a conversation from your own backend? Use the chat API — the stateless widget keeps no transcript.
JavaScript that adds the floating chat button to any page and opens the widget in a popup.
Paste the script tag before </body>. It injects a round button in the agent’s widget colour (bottom-right) and a 380×580 popup holding an iframe of /embed/{api_key} with microphone access; below 440 px wide the popup becomes a bottom sheet. It runs once per page even if included twice.
Served as application/javascript, cacheable for 5 minutes, from any origin. Ready-made snippets for HTML, WordPress, Shopify and easyOrders are on the agent screen.
An unknown key or a disabled widget answers 404 with the JavaScript comment /* Widget not found or disabled */ as the body (application/javascript), so the host page keeps working and no button appears.
A plan that has ended or a used-up message quota does not hide the bubble: the launcher still answers 200, and the chat itself answers with a holding line (see POST /embed/{api_key}/chat).
Parameter
In
Type
Description
api_keyrequired
path
string
The agent’s API key (es_…). The widget must be enabled on the agent screen.
HTML
<!-- before </body> -->
<script src="https://everysecond.ai/embed/es_7Hk2Lm9Qx4Rt8Vw1Zc6Bn3Hs5Jd0GaYe/launcher" async></script>
Response
(function(){
if(window.__esLauncher)return;
window.__esLauncher=true;
var C='#f59e0b',U='https://everysecond.ai/embed/es_7Hk2Lm9Qx4Rt8Vw1Zc6Bn3Hs5Jd0GaYe',isOpen=false;
/* ... builds #es-btn and #es-popup (iframe src=U, allow="microphone") ... */
})();
Errors (1)
Status
Code
When
404
Not found
Unknown key or widget disabled — body /* Widget not found or disabled */.
The chat page itself (HTML), meant to be framed — the launcher’s iframe points here.
Frameable from any site (X-Frame-Options: ALLOWALL, Content-Security-Policy: frame-ancestors *). If you frame it yourself instead of using the launcher, add allow="microphone" so the voice-note button works.
The page talks to POST /embed/{api_key}/chat, keeps the recent history and a visitor_id (used for the per-visitor rate limit and, for agents that take orders, the visitor’s durable chat) in the visitor’s browser.
An unknown key or a disabled widget answers 403 with an HTML error page (unlike the launcher’s 404). A plan that has ended or a used-up quota still serves the page (200); the replies pause instead.
Parameter
In
Type
Description
api_keyrequired
path
string
The agent’s API key (es_…).
color
query
string
A #rrggbb colour that overrides the saved widget colour (used by the dashboard’s live preview).
HTML
<iframe src="https://everysecond.ai/embed/es_7Hk2Lm9Qx4Rt8Vw1Zc6Bn3Hs5Jd0GaYe"
allow="microphone" title="Chat with us"
style="width:380px;height:580px;border:0;border-radius:16px"></iframe>
Errors (1)
Status
Code
When
403
Forbidden
Unknown key or widget disabled (Widget not found or disabled., HTML error page).
One visitor message in, the agent’s reply out — the endpoint the widget page calls.
Stateless by default: the browser sends the recent history in messages (up to 20 { role, content } turns) and nothing is stored beyond usage counting. Once the agent captures a phone number, a website lead is created or updated (firing lead.created / lead.updated). The response is { "reply": … }, plus transcript for a voice note. If the agent’s model fails, the answer is still 200 with an apology as reply.
Agents that take orders run the widget on the chat API instead: send a visitor_id (16–64 letters, digits, - or _; the widget keeps one per browser) and the visitor gets one durable chat with memory, basket and orders. messages is then ignored, and the response adds products (product cards), image when the agent sent a picture, status (as in the chat API) and the visitor_id to keep — a missing or malformed one is replaced by a new random id. When the agent stays silent or a teammate has taken over, reply is a short “our team will follow up” line.
Voice notes: send multipart/form-data with an audio file (up to 10 MB) instead of message, and messages as a JSON string. The note is transcribed and answered like typed text; the audio is not kept.
When the subscription has ended or the message quota is used up, the answer is 200 (by design, not an error — replies pause) with a holding line as reply, in Arabic or English following the conversation: Thanks for reaching out. Automatic replies are unavailable right now — our team will get back to you shortly. Each answered message counts toward the plan’s quota.
CORS is open to every origin and no CSRF token is needed. Limits: 30 messages per minute per visitor — the visitor_id the widget sends (16–64 letters, digits, - or _), or the IP when there is none — plus a ceiling of 600 messages per minute across the whole key, and 10 voice notes per minute per visitor IP. A 429 carries Retry-After (seconds).
Parameter
In
Type
Description
api_keyrequired
path
string
The agent’s API key (es_…). The widget must be enabled.
message
body
string
The visitor’s text, up to 2000 characters. Required unless audio is sent; cannot be combined with audio.
audio
body
file
A voice recording (multipart), up to 10 MB. Required unless message is sent.
messages
body
array<object>
Recent history kept by the browser, up to 20 items of { "role": "user" | "assistant", "content": "…" }. A JSON string in multipart requests. Ignored for agents that take orders.
visitor_id
body
string
The browser’s visitor id, 16–64 characters of letters, digits, - or _ (the widget always sends one). Rate limits are counted per visitor id; agents that take orders also key the visitor’s durable chat on it.
{
"reply": "أهلاً بيك في كشري النيل! تحب تعرف المنيو ولا أسعار التوصيل؟"
}
Errors (5)
Status
Code
When
403
Forbidden
Widget not found or disabled. — unknown key or widget switched off.
422
invalid_audio
audio is not a voice recording.
422
voice_unreadable
Nothing could be made out of the voice note; ask the visitor to retry or type.
422
Validation
message missing or over 2000 characters, both message and audio sent, audio over 10 MB, or more than 20 history items.
429
Rate limited
Rate limit exceeded. Please slow down. (30 messages/minute from this visitor, or 600/minute across the key) or Too many voice notes — please wait a moment. (10 per minute per visitor IP). Retry-After says how many seconds to wait.
Build and maintain an agent without a browser: read and edit its settings, teach it what the business knows, and ask for a channel to be connected. The key is the agent, so no endpoint takes an agent id.
Changes are written through the same models the dashboard uses, and the events describe the agent, not the door: agent.updated, knowledge.added and knowledge.deleted fire with the same payload whether the change was made through this API or by hand in the dashboard (the agent screen, the setup portal, the app); data.source says which — "api" or "dashboard". connection.requested comes only from this API (source: "api"): in the dashboard the owner connects a channel directly, and channel.connected fires whenever a Facebook or WhatsApp connection completes. Nothing fires from the Training Yard (test mode).
Its configuration, its industry and which channels it currently answers on.
agent is the same object agent.updated and channel.connected carry. comment_reply is the canned public reply to social comments (null when unset); reply_verbosity is short, balanced or detailed; human_takeover_hours is how long the agent stays quiet in a chat after a person replies in it (default 6, 0 = never). industry is null when none is set; category is the group above a trade (null for a category itself).
connections lists facebook, instagram, whatsapp and widget, each with connected, the page / account / number name and external_id, and the connect_url the owner opens (signed in) to add it. Instagram rides the Facebook page grant, so both share one URL.
Partial update of the agent’s settings — only the keys you send are touched.
PUT on the same path behaves identically (both are partial). Unknown keys are ignored; a body with none of the editable keys answers 422.
instructions replaces the whole list; blank entries are dropped. To add a single rule without a read-modify-write race against the dashboard, use POST /api/v1/agent/instructions.
status: "live" needs a paid plan; on a free plan it answers 403 and nothing is saved. Moving between draft, testing and archived is always allowed.
comment_reply (the canned public reply to social comments) is part of the agent object, so it reads back here and on GET /api/v1/agent.
Side effect: fires agent.updated with source: "api" and changes naming the fields whose value actually changed — a key sent with its current value is not a change, and a PATCH that changes nothing fires nothing.
Parameter
In
Type
Description
name
body
string
What the agent is called, 2–120 characters.
identity
body
string
Up to 255 characters, dropped into the model’s prompt as “You are …”. Include the agent’s name.
tone
body
string
The voice every reply is written in, up to 255 characters.
instructions
body
array<string>
Up to 200 rules of up to 40,000 characters each, 60,000 in all — the same limits as the dashboard. Replaces the list.
reply_verbosity
body
enum: short | balanced | detailed
Reply length. concise (the dashboard’s name for short) is accepted too and reads back as short.
disable_emojis
body
boolean
Strip emoji from every reply.
comment_reply
body
string
Canned public reply to social comments, up to 1000 characters.
human_takeover_hours
body
integer 0–168
How long the agent stays quiet in a Messenger, Instagram or WhatsApp conversation after a person replies in it — from Meta Business Suite, the Instagram or WhatsApp Business app, or the EverySecond inbox. Default 6; 0 = never pause automatically. A longer pause already running (a manual hand-off) is never shortened.
Arabic dialect of replies. auto (default) = the customer’s own dialect, and while it is not clear yet the dialect of business_country; mirror_neutral = the customer’s, else neutral Arabic; or one fixed dialect (msa = فصحى).
ticket_notice
body
enum: agent | silent
When the agent opens a ticket it tells the customer in its own words (agent, default) or says nothing (silent). No fixed sentence is added; one open ticket per customer.
phone_request
body
enum: once | agent | never
When the agent may ask a customer for their phone or WhatsApp number: at most once per conversation (once, default), as the agent’s instructions say with no platform rule (agent), or never. The agent is always told how many times it already asked.
business_country
body
string | null
Where the business is, e.g. United Arab Emirates. Reads local phone numbers (0501234567 → +971501234567) and sets the default dialect. null = detected.
industry_id
body
integer
An existing industry id (from the industries offered in setup), or null.
{
"agent": {
"id": 812,
"name": "Nour",
"status": "live",
"industry_id": 9,
"identity": "Nour, the ordering assistant of Koshary El Nil in Maadi",
"tone": "Professional and precise",
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu."
],
"reply_verbosity": "short",
"disable_emojis": false,
"comment_reply": "شكراً لتعليقك! بعتنالك التفاصيل في رسالة خاصة.",
"human_takeover_hours": 6,
"reply_dialect": "auto",
"ticket_notice": "agent",
"phone_request": "once",
"business_country": null,
"channels": {
"facebook": true,
"instagram": true,
"whatsapp": false,
"widget": true
},
"updated_at": "2026-10-07T11:40:09+00:00"
}
}
Errors (3)
Status
Code
When
403
Plan
This plan cannot take an agent live. Upgrade the subscription first.
422
Nothing to update
None of the editable keys was sent.
422
Validation
A value breaks its rule (e.g. name shorter than 2 characters, an unknown industry_id, a status outside the enum).
Add one rule to the end of the agent’s instruction list.
Answers 200 with the full list after the append. Fires agent.updated with changes: ["instructions"] and source: "api".
Parameter
In
Type
Description
instructionrequired
body
string
3–1000 characters.
BASH
curl -X POST https://everysecond.ai/api/v1/agent/instructions \
-H "Authorization: Bearer $EVERYSECOND_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{ "instruction": "Never promise a delivery time before asking for the district." }'
Response
{
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu.",
"Never promise a delivery time before asking for the district."
]
}
Errors (1)
Status
Code
When
422
Validation
instruction missing, shorter than 3 or longer than 1000 characters.
Every document the agent answers from, as one flat list.
One row per document (however many chunks the index split it into), and total counts documents. id is what DELETE /api/v1/agent/knowledge/{id} takes.
type is how the index groups the document: Manual Text for text, the file name for a file, URL: … for a web page. title (and name) is the title you gave a text block when adding it; without one it is the index’s own label, the first 50 characters of the document. excerpt is the first 300 characters of its text.
Teach the agent a block of text, a file or a web page — one endpoint, three kinds.
type defaults to file when a file is attached, otherwise text.
Text is indexed immediately and answers { document: { id, type: "text", title, chars } }. title is kept and shown in the knowledge listing (GET /api/v1/agent/knowledge) and on the agent screen; without one the listing shows the first 50 characters of the text.
Files (pdf, docx, txt, xlsx, xls, multipart) and pages (url) are extracted asynchronously: the response carries a task_id, and the document becomes answerable once indexing finishes, usually within seconds. The file size limit is the plan’s upload limit (50 MB by default).
Side effect: fires knowledge.added with the same document object and source: "api". Knowledge added in the dashboard fires the same event with source: "dashboard".
Parameter
In
Type
Description
type
body
enum: text | file | url
What you are sending. Default: file if a file is attached, else text.
text
body
string
type=text: the content, 5–100000 characters. Required for text.
title
body
string
type=text: a title for the block, up to 120 characters — shown in the knowledge listing.
file
body
file
type=file: pdf, docx, txt, xlsx or xls, up to the plan’s upload limit. Required for file.
url
body
string
type=url: a valid URL, up to 2000 characters. Required for url.
Text
curl -X POST https://everysecond.ai/api/v1/agent/knowledge \
-H "Authorization: Bearer $EVERYSECOND_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{ "type": "text", "title": "Delivery policy", "text": "We deliver inside Maadi, Zahraa El Maadi and Degla for 25 EGP within an hour. New Cairo is 45 EGP." }'
Missing text / file / url, text shorter than 5 characters, a file of another format or over the limit, an invalid URL.
502
Index
The knowledge service refused it: The knowledge service rejected that text., That page could not be fetched. / That page could not be read., That file is empty, so there is nothing to read from it., The file could not be processed.
Fires knowledge.deleted with { document: { id }, source: "api" } (a document removed in the dashboard fires it with source: "dashboard"). Keeping a price list current is the common job: delete the old document and add the new file in the same script.
Parameter
In
Type
Description
idrequired
path
string
The document id from the listing (api_…, pdf_…, url_…).
Ask for a channel to be connected; get back the URL the account owner must open.
{id} is the channel name: meta (a Facebook page, and the Instagram account linked to it) or whatsapp (a WhatsApp Business number).
Connecting Meta channels is an OAuth grant a person must approve in a browser — no API key can stand in for that consent. So this endpoint records the request, fires connection.requested (source: "api"), and returns connect_url for the owner to open while signed in to the dashboard. The request closes itself (completed) when that channel connects, and channel.connected fires.
One open request per channel: asking again returns the open one with already_open: true (200). Asking for a channel that is already connected answers 409 with the current connections.
Parameter
In
Type
Description
idrequired
path
enum: meta | whatsapp
The channel to connect.
note
body
string
A message for the owner, up to 500 characters.
BASH
curl -X POST https://everysecond.ai/api/v1/agent/connections/whatsapp \
-H "Authorization: Bearer $EVERYSECOND_KEY" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{ "note": "Please connect the branch number 01112345678." }'
Set the persona, teach it, then ask for the channel and wait for the owner to finish the connection — channel.connected tells you when.
TEXT
PATCH /api/v1/agent { "name": "Nour", "identity": "Nour, the ordering assistant of Koshary El Nil", "tone": "Friendly Egyptian Arabic" }
POST /api/v1/agent/knowledge { "type": "text", "text": "...everything about the business..." }
POST /api/v1/agent/connections/meta { "note": "Connect the main page" }
-> send connection.connect_url to the owner; wait for the channel.connected webhook
Every person the agent has spoken to, on any channel. This is the pull side of the lead.* webhooks and serves the identical lead object, so a CRM mapper is written once: webhooks keep you current, the feed backfills what happened while your endpoint was down and seeds the first import.
Scoped to the key’s agent throughout — a key can never read another agent’s people.
Every person the agent has spoken to, oldest first by default, with filters.
Ordered by created_at, then id — ascending by default so an importer can walk forward with since and never skip a row; order=desc for newest first.
since / until filter on the creation time. An unencoded + in the offset (which arrives as a space) is repaired, but encode it as %2B anyway.
flag matches an exact flag name; an unknown name matches nothing, never everything. has_phone=1 keeps only leads with a number you could actually dial. search matches part of the name or phone.
Leads from the Training Yard never appear here.
Parameter
In
Type
Description
since
query
ISO-8601 string
Only leads created at or after this time.
until
query
ISO-8601 string
Only leads created at or before this time.
channel
query
enum: facebook | instagram | whatsapp | website
The stored two-letter codes fb, ig, wp, web are accepted too.
The agent’s flag vocabulary, so a CRM can map labels to its own picklists.
Sorted by name. assigned_by is how the flag is attached: manual (by a person), keywords (when a message matches its keywords) or ai (when the agent judges it applies).
The source of a lead.flagged event is a different list — who attached it THIS time: ai, keyword (singular — a flag listed here as keywords) or manual (a person ticked it on the Leads screen, whatever the flag’s assigned_by). Both names are kept as they are; map keywords → keyword when matching them.
The transcript behind a lead, so whoever picks it up can read what was said before calling.
Only this agent’s own channels are read (its Facebook page, Instagram account, WhatsApp numbers and website/chat-API conversations): a customer who also talks to another business on EverySecond never brings that conversation along, even though a WhatsApp customer is identified by the same phone number everywhere.
The first call returns the newest limit messages; inside a page they read oldest to newest. To read further back, call again with before set to meta.next_before — the id of the oldest message on the page you have. meta.next_before is null (and meta.has_more is false) once you have reached the start. total is the number of messages in this page.
author is contact for the person, agent for what the business sent automatically (the AI’s replies, pictures it sent, order-status updates, follow-ups, campaign templates) and human for a reply a teammate typed (Tracking, the WhatsApp inbox, the Comments screen). Messages recorded before July 2026 carry fewer markers, so an old automatic message can occasionally show as human.
No media is included; for chat-API conversations GET /api/v1/chats/{id}/messages has both.
Parameter
In
Type
Description
idrequired
path
integer
The lead id.
limit
query
integer
1–500 messages per page, default 200.
before
query
integer
A message id: return the page of messages older than it. Pass the previous page’s meta.next_before.
Grouped the way a CRM thinks: contact (who they are), attribution (where they came from), crm (pipeline state set on the Leads screen) and qualification (what the agent worked out). Every key is always present — an absent value is null, never a missing key.
contact.phone_e164 is the field to deduplicate on: local Egyptian mobiles (01…) become +20…, other numbers keep their digits with a +, and a number that cannot be placed is null rather than a guess. Without a phone, deduplicate on contact.external_id — the channel’s own id for the person (WhatsApp number, Messenger/Instagram user id, or api_… for a chat-API customer).
attribution.channel is facebook, instagram, whatsapp or website (channel_code is the stored fb / ig / wp / web). crm.status is one of new, contacted, qualified, proposal, won, lost; crm.source is chat or import.
qualification.score (0–100) rises as the conversation shows intent, so a lead you already imported can become interesting later — that is what lead.updated is for. links.conversation is the transcript endpoint, links.dashboard the lead on the Leads screen.
Subscribe a webhook endpoint to lead.created, lead.updated and lead.flagged, and upsert on the lead id. Nightly (or after an outage), keep the created_at of the newest lead you stored and walk the feed forward from it with the default order=asc — the oldest-first order means pages never skip a row while new leads arrive.
The same bookings as the dashboard calendar and the AI agent, with the same rules — one implementation (ReservationBooking) behind all three. A booking made here holds its slot for the agent and the dashboard; a booking the agent makes in a WhatsApp, Messenger, Instagram or website chat shows up here and on your webhooks.
Every agent books one way, set by the owner in Reservations → Business type and returned by GET /reservations/setup as type: general — a slot on a branch calendar, inside the branch hours, one booking at a time per branch; medical — a doctor's slot inside the doctor's weekly timetable (specialty → clinic → doctor); doctors never block each other; hotel — a room for whole nights, check-in 12:00 → check-out 12:00 business-local time; a check-out and the next check-in share 12:00 without clashing.
Times in: an ISO-8601 string with an offset (2026-10-12T07:30:00Z) is converted; a bare 2026-10-12 10:30 is read as business-local time. Times out are business-local with their offset (+03:00); meta.timezone / setup.timezone name the zone (default Africa/Cairo).
Statuses: pending (made by the AI, waiting for staff), confirmed, rescheduled (moved after it was made), cancelled, and for tourism waitlisted (on a full departure's waiting list, with waitlist_position). source says where it was made: ai, dashboard or api.
tourism — a trip PROGRAM runs on dated DEPARTURES, each with a capacity (the maximum number of travelers). A booking takes party_size seats on one departure: it holds them while the travelers of the departure's pending, confirmed and rescheduled bookings fit in the capacity, otherwise it is created waitlisted. When seats free up (a cancellation, a bigger capacity, a smaller or moved booking) waiting bookings are promoted in line order when they fit — a group that doesn't fit is skipped and a smaller one behind it may go — and the customer is told in their chat when the channel allows it (promotion_notice). A booking holding seats never loses them: a move or a bigger party that doesn't fit is refused (409 full).
Photos: hotels (branches[].photos, the gallery), rooms and trip programs carry photos[] — {id, url, width, height, mime, sort_order}, cover first, resized to ≤ 1600 px. The agent sends them in chat when a customer asks to see the hotel, a room or a trip.
The agent's kind of business, its branches and hours, the clinic tree (specialties → clinics → doctors with timetables), the rooms, the trip programs with their upcoming departures, and every photo — everything you need to build a booking form.
Switched-off doctors, rooms, programs and departures are left out. Doctor schedule is {"0": ["09:00-13:00", "17:00-21:00"], …} (0 = Sunday); a doctor without one works the branch hours. Branch available_time is {"0": "09:00-17:00" | "closed", …}; empty = always open.
tourism: branches[].programs[] (meta: price per traveler, duration, description, itinerary, highlights[], meeting_point) each with its upcoming departures[] (≤ 60) and their seats: capacity, booked, seats_left, waitlist, price (the departure's own, else the program's). Photos: branches[].photos (a hotel's gallery), rooms[].photos, programs[].photos.
Free slots of a branch (general) or of doctors (medical) on one day — the rooms free for a whole stay (hotel) — or the departures with their seats left (tourism).
general: one entry per branch with slots[] every duration minutes (default the branch's slot length) inside its hours; medical: one entry per active doctor (or only doctor_id / the doctors of branch_id / specialty_id) with slots[] of the doctor's own length; hotel: every active room with available for the whole stay.
tourism: every active upcoming departure from from to to (default today → +90 days, at most 400) — the departure object plus fits (seats_left ≥ party_size) and waitlist_possible (the group is not bigger than the capacity). A departure that doesn't fit can still be booked: the booking goes on its waiting list.
Slots in the past are available: false. The AI agent offers customers exactly these free slots, so a time you book here disappears from its offer at once.
Parameter
In
Type
Description
daterequired
query
YYYY-MM-DD
general / medical: the local day.
branch_id
query
integer
general: one branch; medical / hotel: only its doctors / rooms.
doctor_id
query
integer
medical: one doctor.
specialty_id
query
integer
medical: only the doctors of this specialty.
duration
query
integer 5–480
Slot length in minutes (general default: the branch slot length; medical default: the doctor's).
check_in
query
YYYY-MM-DD
hotel (required): the arrival day.
nights
query
integer 1–60
hotel: default 1.
room_type
query
string
hotel: only rooms of this type (case-insensitive).
guests
query
integer
hotel: only rooms that hold this many people.
from / to
query
YYYY-MM-DD
tourism: the local days to list departures for (default today → +90; at most 400 days).
Bookings that touch a range of local days — or, for syncing, every booking changed since a moment.
Days mode (default today → +30 days): every booking that overlaps the days (a stay that began earlier still occupies its room), soonest first, cancelled ones included unless you filter status.
Sync mode (updated_since): every booking created or changed after that moment — cancellations included — oldest change first. Store meta.next_updated_since (the last row's updated_at) and pass it on the next call; walking pages forward never skips a row.
Parameter
In
Type
Description
from
query
YYYY-MM-DD
First local day (days mode).
to
query
YYYY-MM-DD
Last local day (days mode).
updated_since
query
ISO-8601 string
Sync mode: changes after this moment. URL-encode the +.
Create a booking — a branch slot, a doctor's slot or a room for nights — checked exactly like a staff booking in the dashboard.
general: branch_id (optional when the agent has one branch), start_at, end_at. medical: doctor_id (or resource_id) and start_at — the end is one slot of that doctor; it must sit inside the doctor's timetable. hotel: room_id (or resource_id), check_in, nights (or start_at / end_at whose days are used) — the stay runs 12:00 → 12:00.
The booking is serialised with every other writer on the branch (the AI and the dashboard included), so two systems can never both get the same slot. Fires reservation.created with changed_by: api.
Send your own id as external_ref (unique per agent): a second booking with the same ref is refused with 409 and the existing id, so a retried request never books twice.
tourism: departure_id and party_size (travelers). It answers 201 either way — status: confirmed (or the status you sent) when the seats are free, status: waitlisted with waitlist_position when they are not (and also fires reservation.waitlisted); send allow_waitlist: false to get 409 full instead. A group bigger than the departure's capacity is refused (party_too_large).
Parameter
In
Type
Description
customer_namerequired
body
string ≤ 150
Who the booking is for.
customer_phone
body
string ≤ 30
Their number.
branch_id
body
integer
general: the branch (a doctor or room implies its own branch).
doctor_id / room_id / resource_id
body
integer
medical / hotel: the doctor or the room (aliases of one field).
start_at
body
ISO-8601 or local "Y-m-d H:i"
general / medical: the start; must be in the future.
end_at
body
ISO-8601 or local "Y-m-d H:i"
general: required; medical: defaults to one slot.
check_in
body
YYYY-MM-DD
hotel: arrival day (today or later).
nights
body
integer 1–60
hotel: default 1.
title
body
string ≤ 200
Defaults to the doctor / room, or the customer name.
notes
body
string ≤ 1000
Anything for staff (complaint, guests, requests).
status
body
enum: confirmed | pending
Default confirmed; pending puts it in staff's "needs confirmation" queue.
external_ref
body
string ≤ 100
Your own id for the booking (unique per agent).
lead_id
body
integer
Link it to one of this agent's leads (from the leads feed or a chat).
departure_id
body
integer
tourism: the departure (from setup / availability).
party_size
body
integer 1–200
tourism: how many travelers (default 1).
allow_waitlist
body
boolean
tourism: default true — a booking that does not fit waits in line; false → 409 full.
Move a booking (time, doctor, room, stay dates) — re-checked like a new booking — or change its details. PUT works the same.
A move sets the status rescheduled and fires reservation.updated with change: rescheduled and the old values in previous; a details-only change fires change: edited.
A start-only move keeps the length. A stay already under way keeps its own (past) check-in day, so you can extend or shorten it. A cancelled booking cannot be moved — make a new one.
tourism: departure_id and/or party_size. A booking holding seats keeps them unless the change fits (409 full with details.seats_left); a waiting one moved to another departure joins the back of that line. Seats freed by the change promote the waiting list.
Parameter
In
Type
Description
start_at / end_at
body
ISO-8601 or local "Y-m-d H:i"
New time (general / medical).
resource_id
body
integer
Another doctor / room of the same branch.
check_in / nights
body
YYYY-MM-DD / integer
New stay dates (hotel).
departure_id / party_size
body
integer / integer
Another departure / another number of travelers (tourism).
Confirm a booking — e.g. one the AI made (pending). Give a time for an unscheduled day request (all_day: true). A waitlisted booking is promoted onto its departure when its seats are free (409 no_seats otherwise).
Parameter
In
Type
Description
time
body
HH:MM
Local start for a day request (not for stays).
end_time
body
HH:MM
Default: one slot / the branch slot length.
BASH
curl -X POST https://everysecond.ai/api/v1/reservations/517/confirm -H "Authorization: Bearer $EVERYSECOND_KEY"
Cancel a booking (idempotent). Its slot, room or seats are free again for the agent and the dashboard; fires reservation.cancelled — and, on a tour departure, promotes the waiting bookings that now fit (reservation.promoted).
BASH
curl -X POST https://everysecond.ai/api/v1/reservations/482/cancel -H "Authorization: Bearer $EVERYSECOND_KEY"
Push: subscribe a webhook endpoint to reservation.created, reservation.updated, reservation.confirmed, reservation.cancelled, reservation.waitlisted and reservation.promoted. They fire for every booking — made by the agent in a chat, by staff in the dashboard, through this API, or by an automatic waiting-list promotion — after the database commit, with the full reservation (the exact JSON GET /reservations/{id} returns), change, changed_by (ai · dashboard · api · system for an automatic promotion, with trigger: {reason, by}) and, on a move, the old values in previous.
Setup: reservation_resource.created|updated|deleted (specialties, clinics, doctors, rooms, trip programs), departure.created|updated|deleted, departure.full / departure.reopened and reservation_setup.updated (the kind of business, a hotel's gallery) keep your copy of the setup in step without polling /reservations/setup.
Pull: poll GET /reservations?updated_since=<cursor> every few minutes and on startup; keep meta.next_updated_since as the cursor. It returns every change after the cursor, cancellations included, oldest first — so it also backfills anything your endpoint missed while it was down.
Ids: send external_ref when you create a booking, keep our id next to yours, and look bookings up with GET /reservations/ref/{external_ref}. A retried create with the same ref returns 409 duplicate_external_ref with the existing id instead of booking twice.
Your own bookings come back on your webhook too (changed_by: api) — ignore those, or use them as the acknowledgement.
Training Yard (test mode) bookings never reach the API or webhooks.
A minimal sync loop (Node)
let cursor = await db.get('es_cursor') ?? new Date(Date.now() - 864e5).toISOString();
for (let page = 1; ; page++) {
const r = await fetch(`https://everysecond.ai/api/v1/reservations?updated_since=${encodeURIComponent(cursor)}&per_page=200&page=${page}`,
{ headers: { Authorization: `Bearer ${process.env.EVERYSECOND_KEY}` } }).then(x => x.json());
for (const b of r.data) await upsertBooking(b.external_ref ?? `es-${b.id}`, b); // status 'cancelled' frees the slot
if (r.meta.next_updated_since) cursor = r.meta.next_updated_since;
if (page >= r.meta.last_page) break;
}
await db.set('es_cursor', cursor);
tourism: the agent sees every active trip (price per person, duration, highlights, itinerary, meeting point) with its next 8 departures, their seats left and waiting list; it books the travelers on the departure the customer chose (pending, created_by: ai). When a departure does not have the seats it says so honestly and offers another date or the waiting list — and tells the customer their place in line. A promotion later is announced in the same chat (inside Meta's 24-hour window).
Photos: when a customer asks to see the hotel, a room (or a room type) or a trip, the agent sends up to 3 of its photos, cover first, on WhatsApp, Messenger, Instagram, the chat API (image) and the widget.
medical: the agent sees every active doctor, their specialty and clinic, fee, slot length and their free times for the next 7 days; it offers the earliest free slots, confirms with the customer, and books the exact slot (pending, created_by: ai).
hotel: the agent sees the room types (capacity, price) and how many rooms of each type are free per night for the next 21 nights; it books a free room of the asked type for the whole stay — check-in 12:00, check-out 12:00.
general: the agent books a day at a branch (all_day: true, pending); staff — or your system via /confirm with a time — give it its time.
When a slot was taken a moment earlier, the agent tells the customer honestly and offers another time — it never claims a booking that did not happen.
Read the orders your agent takes in chat, confirm or cancel them from your own back office, keep the menu in step with your store or POS by your own product ids, and report fulfilment statuses that the customer hears about in the same conversation.
Every route is scoped to the one agent (project) the API key belongs to and needs the Orders module active for it. With Orders off, every route here answers 403 and no order or cart webhooks are sent. Turn it on in Finance, then enable Orders for the agent on the Orders screen.
Send the key as Authorization: Bearer <key> or X-Api-Key: <key>. The limit is 120 requests per minute per key, shared by every route the key calls; responses carry X-RateLimit-Limit and X-RateLimit-Remaining, and a 429 carries Retry-After.
Errors are {"error": "…"} with a human-readable message, plus a details list when there is more than one problem. One error also has a machine-readable code: a status move the order cannot make (a late confirm, cancelling a delivered order, a status going backwards) is 409 with "code": "invalid_transition" and details: {status, requested, allowed}. Money is a JSON number that keeps its decimal (240.0) in the agent's currency — orders, carts and their lines alike — encoded exactly like the webhooks, so an order read here is byte-comparable with the one pushed to your endpoint.
An order has one shape everywhere: GET /orders, GET /orders/{id}, the confirm / cancel / status responses, and the order inside every order.* and cart.converted webhook. Line prices are what the item was sold at (offers applied) — never re-price from your catalogue.
Orders of this agent, newest first, paginated — the catch-up feed after downtime.
Sorted by id, newest first. To replay what you missed, pass the last order id you processed as since_id, walk every page, and process the result in ascending id order.
Filters are checked: a status that is not an order status, or a since that cannot be read as a date, is refused with 422 rather than ignored.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Branch not found
branch_id is not a branch of this agent: Branch 12 does not belong to this project.
422
Bad since
since could not be read as a date: since must be a date, e.g. 2026-07-28T00:00:00Z.
422
Bad status
status is not one of the order statuses: status must be one of pending, confirmed, preparing, ready_for_pickup, out_for_delivery, delivered, cancelled.
One order, in exactly the shape the webhooks send.
Fields: status; merchant_ref (your order number), payment_url, tracking_url; requires_staff_approval and awaiting_approval (true until staff confirm an order that needs approval); created_by (ai for chat orders, user for orders typed on the dashboard); channel (whatsapp, messenger, instagram, api, website, playground, dashboard, or null on old orders); chat_id (the chat API chat, when channel is api); conversation_url (opens the conversation in the dashboard).
customer has name, phone (as given), phone_e164 (read in the agent's country; null when it cannot be a phone number), language, sender_id and lead_id. fulfillment_type is delivery or pickup; delivery orders add address, address_details (area, street, building, unit, lat, lng, notes — only the parts given; area falls back to the matched zone) and delivery_zone.
Each line in items carries menu_item_id, your external_id, offer_id and components (a bundle is one line listing what is in it), name, options, unit_price, qty and line_total. customer_confirmed_at is the customer's yes in chat, staff_confirmed_at the staff confirmation.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Order not found
No order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".
The staff gate from your back office: moves a pending order to confirmed, exactly like the dashboard button.
No body. Sets status to confirmed and records staff_confirmed_at (kept if already set), which also lifts awaiting_approval. Sends order.confirmed (confirmed_by api) and order.status_changed.
Only a pending order is confirmed. One that is already confirmed answers 200 with the order and sends nothing again, so a retry is safe. One that is further along (preparing, ready_for_pickup, out_for_delivery, delivered) or cancelled is refused with 409 invalid_transition and stays where it is — a late confirm never drags an order back.
Does not message the customer — use POST /orders/{id}/status with confirmed for that.
Parameter
In
Type
Description
idrequired
path
integer
The order id (order.id in payloads).
BASH
curl -X POST https://everysecond.ai/api/v1/orders/512/confirm \
-H "Authorization: Bearer $EVERYSECOND_KEY"
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Order not found
No order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".
409
invalid_transition
The order is past pending (or cancelled). {"error": "Order 512 is out_for_delivery; only a pending order can be confirmed.", "code": "invalid_transition", "details": {"status": "out_for_delivery", "requested": "confirmed", "allowed": ["delivered", "cancelled"]}} — allowed is where the order can go now through the status API (empty for a delivered or cancelled order). Nothing was changed.
Cancels the order, gives its reserved stock back and, by default, tells the customer in their chat.
The body is optional. Sends order.cancelled (cancelled_by api) and order.status_changed, and messages the customer through the same path as POST /orders/{id}/status (in their conversation and language, or the WhatsApp template mapped to cancelled outside the 24-hour window). notification in the response says what happened, with the same values as the status API.
Works until the order is complete — unlike the customer's own cancel in chat, also while it is preparing or out_for_delivery. A delivered order is refused with 409 invalid_transition (to correct a delivery that never happened, use the status API with "force": true). Cancelling an order that is already cancelled returns it with 200, notification.reasonstatus_unchanged, and sends nothing.
Parameter
In
Type
Description
idrequired
path
integer
The order id (order.id in payloads).
notify_customer
body
boolean
Default true. false cancels without messaging the customer (notification.reason: not_requested).
message
body
string | null
Up to 1000 characters: your own text instead of the standard "Your order SO-1042 was cancelled." line.
BASH
curl -X POST https://everysecond.ai/api/v1/orders/512/cancel \
-H "Authorization: Bearer $EVERYSECOND_KEY"
Cancel with your own words
curl -X POST https://everysecond.ai/api/v1/orders/512/cancel \
-H "Authorization: Bearer $EVERYSECOND_KEY" \
-H "Content-Type: application/json" \
-d '{"message": "Sorry, the kitchen closed early tonight. Your order SO-1042 is cancelled and nothing was charged."}'
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Order not found
No order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".
422
Validation failed
{"error": "Validation failed.", "details": ["notify_customer must be true or false."]} — also for a message over 1000 characters.
409
invalid_transition
The order was already delivered. {"error": "Order 512 is out_for_delivery; only a pending order can be confirmed.", "code": "invalid_transition", "details": {"status": "out_for_delivery", "requested": "confirmed", "allowed": ["delivered", "cancelled"]}} — allowed is where the order can go now through the status API (empty for a delivered or cancelled order). Nothing was changed.
Your system reports what happened to the order, and by default the customer hears about it in the chat they ordered in.
Statuses move forward only: pending → confirmed → preparing → ready_for_pickup (pickup) or out_for_delivery (delivery) → delivered, and cancelled from anything not yet delivered. Skipping steps forward is fine (pending → delivered). A move back, a step sideways or cancelling a delivered order answers 409 invalid_transition with the statuses allowed now — send "force": true to correct a mistake. A cancelled order never moves again. confirmed or any fulfilment status also records the staff confirmation (staff_confirmed_at) if the order had none.
Events: when the status really changes you get order.status_changed, plus order.confirmed (confirmed_by api) for a forward move to confirmed or order.cancelled (cancelled_by api) for cancelled. order.updated is sent, with changed naming them, only when tracking_url or merchant_ref really changed — re-sending the same values sends nothing.
The customer is messaged only when the status actually changed, the move was not forced and notify_customer is not false. Within 24 hours of their last message it goes into the chat; after that a WhatsApp order uses the template mapped to the status (PUT /orders/status-templates), and Messenger/Instagram get nothing. notification tells you which: sent, via (chat | template | null), and when not sent a reason (not_requested, status_unchanged, forced_correction, no_conversation, channel_disconnected, outside_messaging_window, outside_messaging_window_no_template, template_not_approved, template_invalid with error, insufficient_whatsapp_credits, template_send_failed, unsupported_channel). A template send also returns campaign_id. The status is saved whatever the notification outcome.
The new status — forward only (pending → confirmed → preparing → ready_for_pickup | out_for_delivery → delivered; cancelled from anything not yet delivered). ready_for_pickup is for pickup orders only, out_for_delivery for delivery orders only; cancelled releases reserved stock and is final. Sending the current status changes nothing.
tracking_url
body
string (URL) | null
Up to 2048 characters. Saved on the order and added to the customer message for preparing, ready_for_pickup and out_for_delivery. null clears it.
merchant_ref
body
string | null
Your order number, up to 191 characters, unique on this agent (409 if another order has it). null clears it.
notify_customer
body
boolean
Default true: tell the customer in the conversation they ordered in. Only a real status change is announced.
message
body
string | null
Up to 1000 characters: your own text instead of the standard line (which is in the customer's language, with the order number and tracking link).
force
body
boolean
Default false. true corrects a mistake: allows a move back (e.g. delivered → preparing) or cancelling a delivered order. The correction is saved and sent as order.status_changed (plus order.cancelled for a cancel; a step back to confirmed does not re-send order.confirmed), and the customer is never messaged about it (notification.reason: forced_correction). It cannot reopen a cancelled order.
Out for delivery, with tracking and your order number
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Order not found
No order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".
422
Validation failed
{"error": "Validation failed.", "details": ["status must be one of confirmed, preparing, ready_for_pickup, out_for_delivery, delivered, cancelled."]} — also for a bad tracking_url, a too-long merchant_ref/message, or a non-boolean notify_customer.
409
invalid_transition
The move is not forward — a step back, a step sideways, or cancelling a delivered order — and force was not true; or the order is cancelled, which is final even with force (sending cancelled again is accepted and changes nothing). {"error": "Order 512 is out_for_delivery; only a pending order can be confirmed.", "code": "invalid_transition", "details": {"status": "out_for_delivery", "requested": "confirmed", "allowed": ["delivered", "cancelled"]}} — allowed is where the order can go now through the status API (empty for a delivered or cancelled order). Nothing was changed.
422
Awaiting staff approval
The order holds an item that needs staff approval and is not confirmed yet; preparing, ready_for_pickup, out_for_delivery and delivered are refused until you send confirmed (or call /confirm).
422
Wrong fulfilment status
out_for_delivery on a pickup order ("use ready_for_pickup"), or ready_for_pickup on a delivery order ("use out_for_delivery").
409
merchant_ref taken
Another order of this agent already has that merchant_ref. Nothing was changed.
The order that carries your merchant_ref, in the usual order shape.
A merchant_ref is unique per agent: the status API refuses one that another order has (409), and one returned by order.validate that is already taken is not attached. Should two older orders still share a ref, the newest is returned.
Parameter
In
Type
Description
idrequired
path
string
Your order number (merchant_ref), URL-encoded; cannot contain /.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Ref not found
No order on this agent carries that merchant_ref: No order with merchant_ref "SO-1042" on this agent.
Same as POST /orders/{id}/status, addressed by your merchant_ref.
Same body, rules, events, customer notification and response as the id form. Sending a different merchant_ref in the body renames the order (409 if that ref is taken).
Statuses move forward only: pending → confirmed → preparing → ready_for_pickup (pickup) or out_for_delivery (delivery) → delivered, and cancelled from anything not yet delivered. Skipping steps forward is fine (pending → delivered). A move back, a step sideways or cancelling a delivered order answers 409 invalid_transition with the statuses allowed now — send "force": true to correct a mistake. A cancelled order never moves again. confirmed or any fulfilment status also records the staff confirmation (staff_confirmed_at) if the order had none.
Events: when the status really changes you get order.status_changed, plus order.confirmed (confirmed_by api) for a forward move to confirmed or order.cancelled (cancelled_by api) for cancelled. order.updated is sent, with changed naming them, only when tracking_url or merchant_ref really changed — re-sending the same values sends nothing.
The customer is messaged only when the status actually changed, the move was not forced and notify_customer is not false. Within 24 hours of their last message it goes into the chat; after that a WhatsApp order uses the template mapped to the status (PUT /orders/status-templates), and Messenger/Instagram get nothing. notification tells you which: sent, via (chat | template | null), and when not sent a reason (not_requested, status_unchanged, forced_correction, no_conversation, channel_disconnected, outside_messaging_window, outside_messaging_window_no_template, template_not_approved, template_invalid with error, insufficient_whatsapp_credits, template_send_failed, unsupported_channel). A template send also returns campaign_id. The status is saved whatever the notification outcome.
Parameter
In
Type
Description
idrequired
path
string
Your order number (merchant_ref), URL-encoded; cannot contain /.
The new status — forward only (pending → confirmed → preparing → ready_for_pickup | out_for_delivery → delivered; cancelled from anything not yet delivered). ready_for_pickup is for pickup orders only, out_for_delivery for delivery orders only; cancelled releases reserved stock and is final. Sending the current status changes nothing.
tracking_url
body
string (URL) | null
Up to 2048 characters. Saved on the order and added to the customer message for preparing, ready_for_pickup and out_for_delivery. null clears it.
merchant_ref
body
string | null
Your order number, up to 191 characters, unique on this agent (409 if another order has it). null clears it.
notify_customer
body
boolean
Default true: tell the customer in the conversation they ordered in. Only a real status change is announced.
message
body
string | null
Up to 1000 characters: your own text instead of the standard line (which is in the customer's language, with the order number and tracking link).
force
body
boolean
Default false. true corrects a mistake: allows a move back (e.g. delivered → preparing) or cancelling a delivered order. The correction is saved and sent as order.status_changed (plus order.cancelled for a cancel; a step back to confirmed does not re-send order.confirmed), and the customer is never messaged about it (notification.reason: forced_correction). It cannot reopen a cancelled order.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Ref not found
No order on this agent carries that merchant_ref: No order with merchant_ref "SO-1042" on this agent.
422
Validation failed
{"error": "Validation failed.", "details": ["status must be one of confirmed, preparing, ready_for_pickup, out_for_delivery, delivered, cancelled."]} — also for a bad tracking_url, a too-long merchant_ref/message, or a non-boolean notify_customer.
409
invalid_transition
The move is not forward — a step back, a step sideways, or cancelling a delivered order — and force was not true; or the order is cancelled, which is final even with force (sending cancelled again is accepted and changes nothing). {"error": "Order 512 is out_for_delivery; only a pending order can be confirmed.", "code": "invalid_transition", "details": {"status": "out_for_delivery", "requested": "confirmed", "allowed": ["delivered", "cancelled"]}} — allowed is where the order can go now through the status API (empty for a delivered or cancelled order). Nothing was changed.
422
Awaiting staff approval
The order holds an item that needs staff approval and is not confirmed yet; preparing, ready_for_pickup, out_for_delivery and delivered are refused until you send confirmed (or call /confirm).
422
Wrong fulfilment status
out_for_delivery on a pickup order ("use ready_for_pickup"), or ready_for_pickup on a delivery order ("use out_for_delivery").
409
merchant_ref taken
Another order of this agent already has that merchant_ref. Nothing was changed.
Which approved WhatsApp template announces each status once the 24-hour window has closed.
Always lists all six statuses your system can set; null means no template, so a WhatsApp customer outside the window is not told (reason outside_messaging_window_no_template).
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
Set or clear the WhatsApp template used for each status outside the 24-hour window.
Only the statuses you send change; the others keep their mapping. null, "" or 0 removes a mapping. On any error nothing is saved.
Template ids come from GET /api/v1/whatsapp/templates and must belong to the account. Approval is not checked here: a template that is not approved on the customer's WhatsApp account is skipped at send time with reason template_not_approved.
Variables, in order: {{1}} the customer's name, {{2}} the order number (merchant_ref, else #id), {{3}} the status label (English for an English template, otherwise the customer's language), {{4}} the tracking link (- when none). A template uses as many as it declares.
Parameter
In
Type
Description
templatesrequired
body
object
Status → template id or null. Keys: confirmed, preparing, ready_for_pickup, out_for_delivery, delivered, cancelled.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
422
Bad templates
templates is missing or not an object: templates must be an object of status → template id (or null).
422
Unknown status
A key is not one of the six statuses: "shipped" is not a status. Use one of: confirmed, preparing, …
404
Template not found
Template 99 was not found for this account. GET /api/v1/whatsapp/templates lists them.
Baskets customers are building in chat — the same objects the cart.* events carry.
A cart is the running basket the agent has gathered, priced from the menu (offers applied). It holds no stock and is not an order. A customer has at most one active basket; one that converted (became an order, see converted_order_id) or was closed keeps its id and contents, and the next purchase opens a new one.
Sorted by last_activity_at, newest first; no pagination beyond limit. changed_by says who changed it last (agent or api); merchant_quote is your last order.validate answer for it (stage quote), or null.
Each line in items has a line_id — item:{menu_item_id} for a menu item, bundle:{offer_id} for a bundle offer. merchant_quote.items lists your answer per basket line, each entry carrying the line_id of the line it is about (plus its menu_item_id, external_id, offer_id) and the unit_price / available_qty / message you sent; answers about items that were not in the basket are dropped. Quotes stored before 2026-10-07 have no line_id.
Money — unit_price, line_total, items_total, and merchant_quote.total, delivery_fee and items[].unit_price — is always a decimal number (120.0), here and in the cart.* events. To write a basket from your system use PUT /api/v1/chats/{id}/cart (Chat API).
Parameter
In
Type
Description
status
query
enum: active | converted | closed | all
Default active. Any other value is refused with 422.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
422
Bad status
status must be one of active, converted, closed, all.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
Every item of one branch — what the menu_item_id values in orders and carts mean.
Hand-made items (external_id null) and items synced by the menu API side by side, including unavailable ones, sorted by category then name.
effective_price is what the agent would charge right now: the lowest of price, a standing offer_price below it, and any live dashboard campaign. image_url is our copy of the photo once fetched.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
422
Bad updated_since
updated_since must be a date, e.g. 2026-07-28T00:00:00Z.
One item by your product id, with its branch rows.
Item-level fields come from the first branch row. image_url is the URL you sent; branches[].image is the photo we serve (our copy once fetched, else yours).
Parameter
In
Type
Description
idrequired
path
string
Your product id (external_id), URL-encoded. 1–191 characters, no /.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
Create one item by your product id, or merge into the existing one — safe to repeat, never duplicates.
A new external_id is created in branches (every branch by default) and answers 201 with result: "created". An existing one answers 200 with result: "updated".
It is a merge, not a replace: name and price are required every time, but on an existing item the optional fields you leave out keep their current values. Send null to clear an optional field.
branches on an existing item only adds: without it every branch that carries the item is updated; with it the listed branches are written — one that does not carry the item yet gets a copy of it (all its fields except stock) with what you sent on top — and the branches you leave out keep their row. Use DELETE ?branches= to take it out of a branch.
Our menu_item_id never changes on update, so carts, past orders and offers keep pointing at the same product. Unknown fields are ignored. A new or changed image_url is fetched in the background.
Parameter
In
Type
Description
idrequired
path
string
Your product id (external_id), URL-encoded. 1–191 characters, no /.
namerequired
body
string | object {en, ar}
A plain string (English/default) or {"en": "…", "ar": "…"}; each up to 255 characters and at least one of them non-empty. The Arabic name is used when the customer writes Arabic.
pricerequired
body
number
List price, 0 to 99,999,999, rounded to 2 decimals. Cannot be null.
description
body
string | object {en, ar} | null
Same shape as name, each language up to 5000 characters.
category
body
string | null
Up to 191 characters. Helps the agent search large catalogues.
offer_price
body
number | null
A standing sale price (0 to 99,999,999). It applies only while it is below price; the customer pays the lowest of price, offer_price and any live dashboard campaign.
available
body
boolean
false hides the item from the agent at once. Accepts true/false, 1/0, "yes"/"no", "on"/"off".
stock_qty
body
integer | null
Whole number of 0 or more; null = stock not tracked here. Decremented when an order is placed, given back on cancel; 0 means sold out (hidden from the agent).
image_url
body
string (http/https URL) | null
Up to 2048 characters. Copied to our storage in the background (https, no redirects, up to 8 MB, JPEG/PNG/WebP/GIF) for photos in chat and product cards; your URL is used until the copy lands. An http:// URL is fetched as https:// (same host and path); if your host does not serve it over https, the item keeps showing your own URL and is never sent as a photo — prefer https. null removes the copy.
product_url
body
string (http/https URL) | null
Up to 2048 characters. Where a tap on the product card photo leads.
option_group
body
string | null
Up to 191 characters. Variants of one product (sizes, flavours) share a group; each variant is its own item with its own id and price.
options
body
object | null
Up to 20 "name": "value" pairs (name up to 60 characters, scalar value up to 120), e.g. {"size": "Large"}. Copied onto cart and order lines.
branches
body
"all" | array<integer> | integer
Branches to write: "all" or a list of this agent's branch ids. Omitted on create: every branch. Omitted on update: the branches that already carry the item. On an existing item it only adds: a listed branch that does not carry it yet gets a copy of the existing item (all its fields except stock) with what you sent on top, and branches not listed are left as they are (use DELETE ?branches= to remove).
orderable_in_chat
body
boolean
Default true. false: the agent can talk about the item but never puts it in a basket or an order.
max_qty_per_order
body
integer | null
1 to 100000, or null for no limit. The basket is capped at it and a larger chat order is refused.
requires_staff_approval
body
boolean
Orders containing the item are taken but stay awaiting_approval until staff confirm; fulfilment statuses are refused until then.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
422
Validation failed
Every problem at once: {"error": "name is required. price must be a number of 0 or more.", "details": ["name is required.", "price must be a number of 0 or more."]}
422
Bad external_id
external_id must be 1–191 characters with no "/".
422
Bad branches
branches must be "all" or a list of branch ids., or Branch 99 does not belong to this agent.
422
No branch
The agent has no branch yet — create one on the Orders screen first.
Change only the fields you send — e.g. {"available": false} or {"stock_qty": 3}.
The item must already exist (404 otherwise). Without branches, every branch that carries it is updated. Answers 200 with result: "updated".
With branches, only the listed branches are written. A listed branch that does not carry the item yet gets the whole item — the existing item's fields (all but stock) with your changes on top — so {"price": 140, "branches": [9]} puts the item into branch 9 at 140 without touching the other branches.
Parameter
In
Type
Description
idrequired
path
string
Your product id (external_id), URL-encoded. 1–191 characters, no /.
name
body
string | object {en, ar}
A plain string (English/default) or {"en": "…", "ar": "…"}; each up to 255 characters and at least one of them non-empty. The Arabic name is used when the customer writes Arabic.
price
body
number
List price, 0 to 99,999,999, rounded to 2 decimals. Cannot be null.
description
body
string | object {en, ar} | null
Same shape as name, each language up to 5000 characters.
category
body
string | null
Up to 191 characters. Helps the agent search large catalogues.
offer_price
body
number | null
A standing sale price (0 to 99,999,999). It applies only while it is below price; the customer pays the lowest of price, offer_price and any live dashboard campaign.
available
body
boolean
false hides the item from the agent at once. Accepts true/false, 1/0, "yes"/"no", "on"/"off".
stock_qty
body
integer | null
Whole number of 0 or more; null = stock not tracked here. Decremented when an order is placed, given back on cancel; 0 means sold out (hidden from the agent).
image_url
body
string (http/https URL) | null
Up to 2048 characters. Copied to our storage in the background (https, no redirects, up to 8 MB, JPEG/PNG/WebP/GIF) for photos in chat and product cards; your URL is used until the copy lands. An http:// URL is fetched as https:// (same host and path); if your host does not serve it over https, the item keeps showing your own URL and is never sent as a photo — prefer https. null removes the copy.
product_url
body
string (http/https URL) | null
Up to 2048 characters. Where a tap on the product card photo leads.
option_group
body
string | null
Up to 191 characters. Variants of one product (sizes, flavours) share a group; each variant is its own item with its own id and price.
options
body
object | null
Up to 20 "name": "value" pairs (name up to 60 characters, scalar value up to 120), e.g. {"size": "Large"}. Copied onto cart and order lines.
branches
body
"all" | array<integer> | integer
Branches to write. Omitted: every branch that already carries the item. A listed branch that does not carry it yet gets a copy of the existing item (all its fields except stock) with your changes on top. Branches not listed are left as they are.
orderable_in_chat
body
boolean
Default true. false: the agent can talk about the item but never puts it in a basket or an order.
max_qty_per_order
body
integer | null
1 to 100000, or null for no limit. The basket is capped at it and a larger chat order is refused.
requires_staff_approval
body
boolean
Orders containing the item are taken but stay awaiting_approval until staff confirm; fulfilment statuses are refused until then.
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Item not found
No menu item with that external_id on this agent.
422
Validation failed
Every problem at once: {"error": "name is required. price must be a number of 0 or more.", "details": ["name is required.", "price must be a number of 0 or more."]}
422
Bad external_id
external_id must be 1–191 characters with no "/".
422
Bad branches
branches must be "all" or a list of branch ids., or Branch 99 does not belong to this agent.
422
No branch
The agent has no branch yet — create one on the Orders screen first.
Remove an item from every branch, or only from the branches named.
Past orders keep their lines: the line's menu_item_id becomes null and your external_id stays on it. Photos we fetched are deleted; a photo uploaded by hand on the dashboard never is.
Parameter
In
Type
Description
idrequired
path
string
Your product id (external_id), URL-encoded. 1–191 characters, no /.
branches
query
string
Comma-separated branch ids (3,5) or all (default).
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
404
Item not found
No item with that external_id (in the named branches) on this agent.
Up to 500 upserts, patches and deletes in one call; each entry succeeds or fails on its own.
Each entry is {"op": "upsert" | "patch" | "delete", "external_id": "…", …fields} with the same fields and rules as PUT (upsert, the default op) or PATCH. A delete entry may carry branches.
Always 200 when the list itself is valid, even if entries failed — check status per entry and summary.failed. Entries are not one transaction: the good ones are saved even when others fail.
Per-entry status: created, updated, deleted or error (with error). Upserts and patches return menu_item_ids (one per branch).
Parameter
In
Type
Description
itemsrequired
body
array<object>
Non-empty list of at most 500 entries.
items[].op
body
enum: upsert | patch | delete
Default upsert.
items[].external_idrequired
body
string
Your product id, 1–191 characters, no /.
items[].…
body
mixed
The item fields of PUT / PATCH (name, price, available, stock_qty, branches, …).
No key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403
Orders module off
The Orders module is not active for this agent: {"error": "The Orders module is not active for project \"…\". Activate Orders on the Finance page, then enable it for this project on the Orders screen."}
429
Rate limited
More than 120 requests in a minute on this key. Wait the Retry-After seconds.
422
Empty batch
items must be a non-empty list.
422
Batch too large
A batch takes at most 500 items. Nothing is written.
Everything here is optional and additive. A merchant who edits the menu on the dashboard never calls it, and hand-made items (no external_id) live next to synced ones in the same branch menu; a sync never touches an item it did not create.
Address items by your own product id. PUT /orders/menu/items/{external_id} creates the item the first time and merges into it after that (fields you leave out keep their value), so the same call is safe to repeat. Our menu_item_id never changes on update, so carts, past orders and offers keep pointing at the same product, and every cart and order line carries your external_id back to you.
One catalogue, many branches: branches: "all" (the default when an item is created) writes one row per branch, each with its own menu_item_id, all sharing your id. Send a list of branch ids to sell it in some branches only. On an existing item branches only adds (a new branch gets a copy of the item with your changes on top; PUT and PATCH alike) — DELETE …?branches=3,5 takes it out of some.
Initial load and nightly reconciliation: POST /orders/menu/items/batch takes up to 500 entries (upsert, patch or delete), each succeeding or failing on its own; read back what we hold with GET /orders/menu/items?updated_since=…. The key allows 120 requests a minute, so use batches for whole-catalogue syncs and single PATCH calls for live changes.
Availability and stock: available: false hides an item from the agent at once. stock_qty is optional — null means stock is not tracked here; a number is decremented when an order is placed (under a row lock, so two chats cannot sell the last unit twice), given back when the order is cancelled, and 0 means sold out. If your system is the stock master, keep stock_qty: null and PATCH available, or PATCH stock_qty whenever it moves — or answer order.validate with available_qty for a live check at order time.
Prices: price is the list price and offer_price a standing sale price that counts only while it is below price. The customer pays the lowest of the list price, offer_price and any live campaign set on the dashboard; effective_price in the branch menu shows that figure. An order line keeps the price it was sold at.
Variants: give each size or flavour its own item (own id, own price) with the same option_group and options such as {"size": "Large"}. The options are copied onto cart and order lines.
Ordering rules, enforced by the platform on chat orders (orders your staff type on the dashboard are not bound by them): orderable_in_chat: false — the agent can answer questions about the item but never puts it in a basket or an order (in-store only, made to order, prescription items); max_qty_per_order — the basket is capped at it and a larger order is refused with the maximum offered; requires_staff_approval: true — the order is taken but stays awaiting_approval (the customer is told it needs your team's OK) and fulfilment statuses are refused until it is confirmed.
Photos: image_url is copied to our storage in the background (https, no redirects, up to 8 MB, JPEG/PNG/WebP/GIF) so the agent can send it in chat and show it on product cards; until the copy lands, or if your host refuses it, your URL is used. An http:// URL is fetched as https:// from the same host and path; a host that does not serve it over https leaves the item on your own URL (shown, never sent as a photo), so prefer https. image_url: null removes the copy. product_url is where a tap on the photo leads.
The other direction: subscribe to order.created / order.confirmed (or poll GET /orders?since_id=) to create the order in your system, report progress with POST /orders/{id}/status, and keep the two in step with merchant_ref.
Optional. Subscribe one of your webhook endpoints to order.validate (Integration screen, or POST /api/v1/webhooks) and your system gets the final say on price, stock and delivery fee before an order is saved, and can hand back its own order number and a payment link. With no subscriber nothing changes. Only the first active endpoint subscribed to it is asked.
When: stage quote — the customer's basket changed in chat, before the agent reads the summary back. Stage place — the customer said yes, after the platform's own item rules pass and before anything is saved. It is not called when the customer edits an existing order in chat, for orders typed on the dashboard, for baskets your system writes through PUT /chats/{id}/cart, or in the Training Yard (test conversations never reach your system).
The request is a signed POST with the same envelope, headers (X-EverySecond-Signature, X-EverySecond-Event: order.validate, X-EverySecond-Event-Id, X-EverySecond-Delivery) and HMAC as every webhook. data carries stage, branch_id, currency, channel, fulfillment (type, zone, address, address_details), customer (name as the customer gave it, phone as typed, phone_e164 read in the agent's country, language, sender_id, lead_id), the priced items (external_id, menu_item_id or bundle_id, name, options, qty, unit_price — empty fields are left out), items_total, and delivery_fee (the zone fee; 0.0 when the branch has no zones; null for pickup or an address outside every zone).
Your answer: HTTP 2xx with a JSON object that has an ok key. "ok": false (or "false" / 0) refuses; any other value accepts. Optional keys: message (up to 500 characters — what the customer is told when you refuse); items[], matched to our lines by external_id, else menu_item_id, else bundle_id, each with unit_price (your price for that line), available_qty (how many you can supply) and message (up to 300 characters, told to the customer when that line is short) — nothing else is read from an entry: a qty you send is ignored, the quantity is always the customer's; delivery_fee; total; and at stage placemerchant_ref (string or integer, up to 191 characters) and payment_url (http/https only). Amounts must be numbers from 0 to 99,999,999 — anything else is ignored, not rejected.
At quote: the answer is stored on the basket as merchant_quote (visible in GET /orders/carts and in cart.* events) and shown to the agent for two hours, so the read-back uses your total and delivery_fee. merchant_quote.items holds one entry per basket line you answered for, with that line's line_id (the id in cart.items); entries about items not in the basket are dropped. A refusal, or a line whose available_qty is below the basket quantity, is told to the customer in the same reply. Prices in the basket itself stay the menu's.
At place: ok: false — nothing is saved, the customer is told your message, and order.failed arrives with reason rejected_by_merchant. A short available_qty — nothing is saved, the customer is offered what is left, order.failed with reason out_of_stock. Otherwise the order is saved and its total is recomputed, exactly: each line = your unit_price (ours where you named none) × the customer's quantity, plus — delivery orders only — your delivery_fee (ours where you sent none); pickup orders have no fee. Your total is not used at this stage. merchant_ref and payment_url are saved on the order, given to the customer and included in every later payload, and the customer is told the final total if it differs from what the agent quoted.
A merchant_ref that another order of this agent already carries is not attached: the order is still saved (the webhook is fail-open), without a ref, and the clash is logged on our side. Refs must stay unique so /orders/ref/{merchant_ref} finds one order — return a fresh number for every order.
Fail-open: one attempt with a 4-second budget (2 s to connect), no retries, no redirects. No subscriber, a timeout, a non-2xx status, a body that is not a JSON object, or JSON without ok all mean "no answer", and the order goes ahead exactly as it would without the webhook. Your system can never block sales by being down — and only an explicit "ok": false stops an order. Every call is in the endpoint's delivery log with its status, response body and timing.
If you accepted at place but the order still could not be saved (the last unit sold a moment earlier), you receive order.failed: void the merchant_ref you issued. Like every webhook URL, the endpoint must be https and must not resolve to a private address.
An order is pending when the agent saves it (the customer has already said yes: customer_confirmed_at), confirmed once staff accept it (staff_confirmed_at), then preparing, ready_for_pickup (pickup orders) or out_for_delivery (delivery orders), and delivered. cancelled can happen at any point before delivered and is final.
Who moves it: staff on the dashboard; your system through POST /orders/{id}/confirm, POST /orders/{id}/cancel or POST /orders/{id}/status; and the customer in chat, who can change or cancel the order until it reaches preparing — changing the items puts it back to pending and clears the staff confirmation.
Rules your system works under: statuses only move forward (skipping steps is fine); a step back, a step sideways or cancelling a delivered order is 409 invalid_transition (with the statuses allowed now) unless you send "force": true to the status API to correct a mistake. A cancelled order never moves again, force or not. /confirm only confirms a pending order (already confirmed: 200, nothing sent again); /cancel works until delivered. While awaiting_approval is true the fulfilment statuses are refused (422) until the order is confirmed; out_for_delivery is refused on pickup orders and ready_for_pickup on delivery orders (422). Cancelling gives reserved stock back.
Events: every real change sends order.status_changed (previous_status, status) — forced corrections included; a forward move to confirmed also sends order.confirmed and a move to cancelled also sends order.cancelled. Repeating the current status sends nothing.
Telling the customer: POST /orders/{id}/status (and the ref/{merchant_ref} form) and POST /orders/{id}/cancel message the customer by default, in the conversation they ordered in and in their language, with your order number (merchant_ref, else #id) and — for preparing, ready_for_pickup and out_for_delivery — the tracking link. Send message to use your own text, or notify_customer: false to stay quiet. It goes out through the agent's normal reply path, so it is in the transcript, on Tracking, and redelivered if Meta refuses it once. Only a real change is announced, and a forced correction never is. /confirm and the dashboard buttons never message the customer.
The 24-hour window: Meta allows a free-form message only within 24 hours of the customer's last message. Outside it, a WhatsApp order falls back to the approved template you mapped to that status with PUT /orders/status-templates, sent as a one-recipient campaign — it uses WhatsApp credits and gets delivery receipts like any campaign. Template variables, in order: {{1}} customer name, {{2}} order number, {{3}} status label, {{4}} tracking link. Messenger and Instagram have no template fallback, so after 24 hours the update is not sent. Chat API conversations have no window: the message lands in the transcript your app reads.
Every status and cancel response says what happened in notification: {"sent": true, "via": "chat"}, {"sent": true, "via": "template", "campaign_id": 4417}, or "sent": false with a reason — not_requested (you sent notify_customer: false), status_unchanged, forced_correction (a force move), no_conversation (no message from this customer, e.g. an order typed on the dashboard), channel_disconnected (the agent's WhatsApp number or page is no longer connected), outside_messaging_window, outside_messaging_window_no_template, template_not_approved, template_invalid (with error), insufficient_whatsapp_credits, template_send_failed, unsupported_channel. The status itself is saved in every case.
merchant_ref is your own order number, stored on our order so neither side needs a mapping table. It is set by returning merchant_ref from order.validate at stage place, or by sending it with POST /orders/{id}/status.
Once set, it is in every order payload (API and webhooks), in the customer's messages ("Your order SO-1042 is out for delivery") instead of our #512, and in {{2}} of the status templates.
Address an order by it with GET /orders/ref/{merchant_ref} and POST /orders/ref/{merchant_ref}/status. URL-encode it; a ref containing / cannot be used in these paths.
It is unique per agent. Through the status API a ref another order already has answers 409 and nothing changes; "merchant_ref": null clears it. A ref returned by order.validate that another order already has is not attached — the order is saved without it — so return a fresh number for every order. (Should two older orders still share a ref, the ref/ routes use the newest.)
Send Meta-approved WhatsApp template messages from your own backend, such as order confirmations, OTP codes and campaigns, and track delivery for each recipient. Messages go out from the WhatsApp number connected to the agent that owns the API key.
Base URL: https://everysecond.ai/api/v1/whatsapp. Send the agent's API key as Authorization: Bearer <key> or X-Api-Key: <key>. The key decides the project, and with it everything else: you only ever see that project's numbers, templates and campaigns. Another project's ids return 404, even when the same account owns both projects. You never pass a project id.
The usual flow is: call GET /templates, then GET /templates/{id}/schema to see what each recipient must supply, then POST /send to create and queue the campaign in one call. To review a draft first, use POST /campaigns followed by POST /campaigns/{id}/send. Then poll GET /campaigns/{id}, and read the reason for each failure from GET /campaigns/{id}/recipients.
Successful responses put the payload under data. Every failure, including field validation, returns {"error": "<sentence>"}, plus details when there is more to say: a list of lines on 422, or credits_required and credits_balance on 402. There are no machine-readable error codes, so branch on the HTTP status and show error and details to a person.
Template messages are paid from the WhatsApp credit wallet, not from the plan's chat-message quota. No webhook fires when a campaign makes progress, so poll for it.
The approved templates this project can send from its own number(s), with the credit price of one message.
Meta approves a template separately for each WhatsApp Business account (WABA). This list only includes templates with status approved that are approved on at least one of the project's connected numbers. If the project has no number connected yet, every approved template of the project is listed.
The list includes the project's own templates. It also includes older templates, created before templates belonged to a project, if they were submitted to this project's WhatsApp Business account.
approved_on_waba_ids lists the WABA ids (waba_id in GET /accounts) the template can be sent from. It can be empty for old templates that never recorded a WABA, and those can be sent from any number. credits_per_message is the current price of the template's category, and 0 means free.
The top-level project shows which project the key belongs to, so it is the quickest way to check you are using the right key. Results are sorted by name and are not paginated.
Shows exactly what each recipient must supply to send one template: body variables, header variables or media, and a value for every dynamic URL button.
This works for every template of the project, whatever its status, including pending and rejected ones. That lets you build the payload before approval. Check status to see whether the template can be sent yet.
variables has one entry for each distinct {{n}} placeholder in the body, and all of them are required. label is the friendly name given when the template was built in EverySecond, or null for templates synced from Meta. example is the sample value Meta approved, or null if none was stored.
header.type is TEXT, IMAGE, VIDEO, DOCUMENT, or null when there is no header. media_required is true for the three media types, and then every recipient needs header_media_url. For a TEXT header, header.text is the approved text and header.variables lists its {{n}} placeholders; each entry's input (e.g. header_variables.1) names the recipient field to fill, and example is Meta's sample.
buttons lists every URL button whose link ends in {{1}}: its position index in the template, text, url, the recipient field to fill (input, always button_urls.{index}) and an example value. The first one also has alias: "button_url", the field older integrations use. button.has_dynamic_url is true when this list is not empty. Quick-reply buttons and static links need nothing and are not listed.
AUTHENTICATION templates always describe exactly one variable, "1", which is the code. It has required: false and generated_if_missing: true: leave it out and a code is generated for each recipient.
example_recipient is a recipient object you can copy, with only the fields this template needs. Body variable values come from Meta's samples, else from the label, else they are the word "value". A template with one dynamic button gets button_url; one with two gets button_urls. Its phone and name are placeholders.
Lists the WhatsApp numbers connected to this project, which are the senders a campaign can use.
Numbers are ordered by id. The first one has is_default: true, and a send without account_id uses it. Most projects have exactly one number.
Reconnecting an agent on a new number adds a new account with its own waba_id. A template has to be approved on that WABA before it can be sent from the new number.
An empty list means no number is connected, and sends return 409. phone_number_id is repeated on every campaign so you can see which number sent it.
Creates a campaign from a list of recipients and queues it for sending in the same call. Most integrations use this endpoint.
The request succeeds or fails as a whole. The template, the sender and every recipient (variables, media, button and phone number) are checked before anything is stored. Any problem returns 422 with one details line per problem, and nothing is created, queued or charged. So total_recipients always equals the number of recipients you sent, unless you turn on skip_invalid_phones.
Once the checks pass, the campaign is priced as credits_per_message × total_recipients. If the wallet cannot cover that, you get 402 and the campaign is deleted, so nothing is left behind.
On success the campaign is queued, with one send job per recipient scheduled 1 second apart, and becomes processing once the first message starts going out. That is about 60 messages a minute per campaign, so 5000 recipients take roughly 80 minutes. Credits are taken one message at a time as each is handed to Meta, never twice for one recipient, and nothing is reserved up front.
For an AUTHENTICATION template the response also has otp_codes: one entry per recipient in request order, with index (0-based position in your recipients), recipient_id, phone (normalised), code and generated (true when you left variables."1" out and EverySecond made the code). Store the code to verify the user; GET /campaigns/{id}/recipients repeats it as otp_code.
With skip_invalid_phones: true, recipients whose number fails validation are left out and listed under data.skipped. Each entry has index (0-based position in your recipients array), phone (as you sent it) and error. If no number is valid the call is still refused with 422. A missing variable, media URL or button value still fails the whole call.
There is no idempotency key, so repeating this call creates and sends a second campaign. If you need to retry safely, use POST /campaigns and then POST /campaigns/{id}/send (see Idempotency and retries).
Parameter
In
Type
Description
namerequired
body
string
Label for the campaign, shown in the dashboard and in reports. Max 255 characters.
template_idrequired
body
integer
An id from GET /templates. It must belong to this project, have status approved, and be approved on the sending number's WhatsApp Business account.
account_id
body
integer
The sending number, an id from GET /accounts. Leave it out to use the project's default number (the one flagged is_default).
recipientsrequired
body
array<object>
Between 1 and 5000 recipient objects.
recipients[].phonerequired
body
string
Local or international number, punctuation allowed. Max 30 characters, and it must be a JSON string (a JSON number fails validation). It is normalised and validated before anything is stored. See the Phone numbers guide.
recipients[].country_code
body
string
Dial code for this recipient, e.g. "44" or "+44". Max 5 characters. Used only when phone has no + or 00 prefix, and it overrides the sending number's country. A code that is not a real calling code is ignored.
recipients[].name
body
string
Display label in reports, max 255 characters. It is not inserted into the message. To greet someone by name, pass the name in variables.
recipients[].variables
body
object
Required when the template has body placeholders. Use an object keyed by placeholder index as a string, e.g. {"1": "Ahmed", "2": "RC-10293"}. Every placeholder needs a non-empty value. Values are sent as text and extra keys are ignored. The one exception is the code of an AUTHENTICATION template: leave "1" out and a 6-digit code is generated and returned in data.otp_codes.
recipients[].header_variables
body
object
Required when the schema lists header.variables (a {{n}} in a TEXT header, e.g. Order {{1}}). Keyed like variables: {"1": "RC-10293"}. Ignored for templates without header placeholders.
recipients[].header_media_url
body
string (URL)
Required when the schema has header.media_required: true. Must be a valid URL (max 2048 characters) that EverySecond can download when the message is sent. Ignored for templates without a media header.
recipients[].button_url
body
string
Value for the first dynamic URL button (the schema entry whose alias is button_url). Pass either the part that replaces {{1}} in the button link or the full link, which is trimmed down to that part. A plain string, not validated as a URL, max 2048 characters. variables.button_url is also accepted, and so is button_urls with that button's index.
recipients[].button_urls
body
object
One value per dynamic URL button, keyed by the button's index from the schema's buttons[], e.g. {"1": "AB12", "2": "https://shop.example/pay/INV-9"}. Same value rules as button_url. Required for a second dynamic button; for the first one button_url is enough.
skip_invalid_phones
body
boolean
Defaults to false. When true, recipients whose phone fails validation are left out and listed under data.skipped, and the rest of the request goes ahead.
{
"error": "Validation failed.",
"details": [
"Recipient #1: variable {{2}} is required.",
"Recipient #2 (12345): Too short for country code +20.",
"Recipient #3: header_media_url is required for this template (header format IMAGE)."
]
}
A field rule failed: missing name or template_id, empty recipients or more than 5000, a non-string phone, an invalid header_media_url, and so on. It is also returned when a recipient is missing a body variable, a header variable, header media or a button value, or has a number WhatsApp cannot deliver to. details lists one line per problem, e.g. Recipient #1: variable {{2}} (Discount Code) is required., Recipient #1: header_variables.1 is required (the {{1}} in the template's text header). or Recipient #1: button_urls.2 is required (the {{1}} at the end of button "Pay": https://shop.example/pay/{{1}}). Nothing is created.
404
Template {id} was not found for project "…".
The template does not exist or belongs to another project.
422
Template "…" is "pending", not approved by Meta, so it cannot be sent yet.
The template's status is anything other than approved (draft, pending, rejected, paused…).
404
WhatsApp account {id} is not connected to project "…".
The account_id you sent is not one of this project's numbers.
409
Project "…" has no connected WhatsApp number.
No account_id was sent and the project has no WhatsApp number connected.
422
Template "…" is approved on WhatsApp Business account(s) …, but … belongs to ….
Meta approved the template on another number's WhatsApp Business account. Send it from a matching number, or submit the template to this number in the dashboard.
422
No recipient has a valid WhatsApp number.
Only with skip_invalid_phones: every number failed validation. details names each one.
402
Not enough WhatsApp credits: this campaign needs N credit(s) for N recipient(s) but the balance is N.
The wallet balance is below credits_per_message × total_recipients. details is {"credits_required": …, "credits_balance": …}, and the campaign is removed again.
401
Missing API key. / Invalid API key.
No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403
This feature is currently disabled.
WhatsApp Outbound is switched off platform-wide by an admin. Checked after the key, so a bad key still gets 401.
429
Rate limit exceeded (120 requests/minute). Retry in Ns.
More than 120 calls in a minute with this key (shared with every other /api/v1 surface). Wait for the Retry-After header, in seconds.
Takes the same payload and runs the same checks as POST /send, but stores the campaign as a draft and does not queue or charge anything.
Use it when someone should review the list before it goes out, for example in the dashboard's Campaigns screen. It also makes sending safe to retry: create the draft once, then call POST /campaigns/{id}/send.
No credit check happens here. The wallet is checked when the draft is sent.
skip_invalid_phones works the same way as on POST /send, and data.skipped is returned.
The API has no call to edit or delete a draft, so do that in the dashboard. A draft never sends by itself.
Parameter
In
Type
Description
namerequired
body
string
Label for the campaign, shown in the dashboard and in reports. Max 255 characters.
template_idrequired
body
integer
An id from GET /templates. It must belong to this project, have status approved, and be approved on the sending number's WhatsApp Business account.
account_id
body
integer
The sending number, an id from GET /accounts. Leave it out to use the project's default number (the one flagged is_default).
recipientsrequired
body
array<object>
Between 1 and 5000 recipient objects.
recipients[].phonerequired
body
string
Local or international number, punctuation allowed. Max 30 characters, and it must be a JSON string (a JSON number fails validation). It is normalised and validated before anything is stored. See the Phone numbers guide.
recipients[].country_code
body
string
Dial code for this recipient, e.g. "44" or "+44". Max 5 characters. Used only when phone has no + or 00 prefix, and it overrides the sending number's country. A code that is not a real calling code is ignored.
recipients[].name
body
string
Display label in reports, max 255 characters. It is not inserted into the message. To greet someone by name, pass the name in variables.
recipients[].variables
body
object
Required when the template has body placeholders. Use an object keyed by placeholder index as a string, e.g. {"1": "Ahmed", "2": "RC-10293"}. Every placeholder needs a non-empty value. Values are sent as text and extra keys are ignored. The one exception is the code of an AUTHENTICATION template: leave "1" out and a 6-digit code is generated and returned in data.otp_codes.
recipients[].header_variables
body
object
Required when the schema lists header.variables (a {{n}} in a TEXT header, e.g. Order {{1}}). Keyed like variables: {"1": "RC-10293"}. Ignored for templates without header placeholders.
recipients[].header_media_url
body
string (URL)
Required when the schema has header.media_required: true. Must be a valid URL (max 2048 characters) that EverySecond can download when the message is sent. Ignored for templates without a media header.
recipients[].button_url
body
string
Value for the first dynamic URL button (the schema entry whose alias is button_url). Pass either the part that replaces {{1}} in the button link or the full link, which is trimmed down to that part. A plain string, not validated as a URL, max 2048 characters. variables.button_url is also accepted, and so is button_urls with that button's index.
recipients[].button_urls
body
object
One value per dynamic URL button, keyed by the button's index from the schema's buttons[], e.g. {"1": "AB12", "2": "https://shop.example/pay/INV-9"}. Same value rules as button_url. Required for a second dynamic button; for the first one button_url is enough.
skip_invalid_phones
body
boolean
Defaults to false. When true, recipients whose phone fails validation are left out and listed under data.skipped, and the rest of the request goes ahead.
A field rule failed: missing name or template_id, empty recipients or more than 5000, a non-string phone, an invalid header_media_url, and so on. It is also returned when a recipient is missing a body variable, a header variable, header media or a button value, or has a number WhatsApp cannot deliver to. details lists one line per problem, e.g. Recipient #1: variable {{2}} (Discount Code) is required., Recipient #1: header_variables.1 is required (the {{1}} in the template's text header). or Recipient #1: button_urls.2 is required (the {{1}} at the end of button "Pay": https://shop.example/pay/{{1}}). Nothing is created.
404
Template {id} was not found for project "…".
The template does not exist or belongs to another project.
422
Template "…" is "pending", not approved by Meta, so it cannot be sent yet.
The template's status is anything other than approved (draft, pending, rejected, paused…).
404
WhatsApp account {id} is not connected to project "…".
The account_id you sent is not one of this project's numbers.
409
Project "…" has no connected WhatsApp number.
No account_id was sent and the project has no WhatsApp number connected.
422
Template "…" is approved on WhatsApp Business account(s) …, but … belongs to ….
Meta approved the template on another number's WhatsApp Business account. Send it from a matching number, or submit the template to this number in the dashboard.
422
No recipient has a valid WhatsApp number.
Only with skip_invalid_phones: every number failed validation. details names each one.
401
Missing API key. / Invalid API key.
No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403
This feature is currently disabled.
WhatsApp Outbound is switched off platform-wide by an admin. Checked after the key, so a bad key still gets 401.
429
Rate limit exceeded (120 requests/minute). Retry in Ns.
More than 120 calls in a minute with this key (shared with every other /api/v1 surface). Wait for the Retry-After header, in seconds.
Queues a draft campaign for sending, whether it was made through the API or in the dashboard.
Only a draft can be sent. A second call returns 422 naming the campaign's current status, so retrying this call never sends twice.
It applies the dashboard's own rule: a draft is never sent while one of its numbers is invalid. A draft built in the dashboard from a spreadsheet keeps invalid rows so they can be fixed inline, and such a draft gets 422 listing them (details lines like Recipient 12345: Too short for country code +20.). Fix or remove them in the dashboard, or call again with skip_invalid_phones: true: the invalid rows are then removed, as the dashboard's Remove button does, listed under data.skipped as {recipient_id, phone, error}, and the rest is sent. Drafts made through the API never contain invalid rows.
The wallet is checked against credits_per_message × total_recipients next. If it is short you get 402 with the shortfall, and the draft stays a draft (nothing is removed).
One job is queued per valid recipient, 1 second apart, and the response is the campaign with status: "queued". The body is optional.
Parameter
In
Type
Description
idrequired
path
integer
Campaign id: data.id from POST /send or POST /campaigns. Any campaign of this project works, including ones created in the dashboard.
skip_invalid_phones
body
boolean
Defaults to false. When true, invalid rows of the draft are removed and listed under data.skipped instead of refusing the send.
BASH
curl -s -X POST https://everysecond.ai/api/v1/whatsapp/campaigns/90/send \
-H "Authorization: Bearer $EVERYSECOND_KEY"
Dashboard draft with an invalid row: send the rest
Returns the progress and delivery counters of one campaign.
status moves from draft to queued to processing (the first message started going out) to completed, or ends as failed (every recipient failed) or cancelled. The Delivery statuses guide explains each one.
sent counts messages Meta accepted, and they stay counted after they are delivered or read. delivered includes messages that were later read. failed includes cancelled recipients. A message Meta first accepts and later reports as failed moves from sent to failed.
progress_percent is (sent + failed) ÷ total_recipients, so it reaches 100 once every message has been tried. delivered and read keep rising after that as receipts arrive. completed_at is set when the last recipient has been tried (for completed and failed alike), and stays null for cancelled campaigns.
Poll every 10–30 seconds while progress_percent is below 100, then once more a few minutes later to collect the delivery and read receipts.
Parameter
In
Type
Description
idrequired
path
integer
Campaign id: data.id from POST /send or POST /campaigns. Any campaign of this project works, including ones created in the dashboard.
Returns each recipient's status, WhatsApp message id and failure reason, one page at a time.
Rows are ordered by recipient id, which is the order you sent them in.
phone is the normalised international number: digits only, without +. error explains why a recipient failed. It can be Meta's own text with its code (paused template, undeliverable number, media problems and so on), Insufficient WhatsApp credits. if the wallet ran out mid-campaign, Cancelled by user, Could not reach WhatsApp after N attempt(s): … (Meta could not be reached at all, nothing was sent), or a text starting with send_status_unknown (the request may have reached Meta but no answer came back, so it was not resent).
status is pending, sending (a send job has claimed it and the request is in flight, normally a few seconds), sent, delivered, read or failed.
wa_message_id is the WhatsApp message id (wamid.…) once Meta has accepted the message, and sent_at is when that happened. Delivered and read times are not exposed, so use status for those.
credits is what the recipient cost: the price at send time, or 0 if it was never charged or has been refunded. refunded_at is when the credits went back (refused send, unknown outcome, or a later failure receipt), else null.
For an AUTHENTICATION campaign every row also has otp_code: the code that recipient was sent, whether you supplied it or it was generated.
Parameter
In
Type
Description
idrequired
path
integer
Campaign id: data.id from POST /send or POST /campaigns. Any campaign of this project works, including ones created in the dashboard.
per_page
query
integer
Rows per page. Defaults to 100, maximum 500.
page
query
integer
Page number, starting at 1. meta.last_page tells you when to stop.
Stops a queued or processing campaign. Recipients that have not been handed to Meta yet are marked failed and are never sent.
A queued or processing campaign can be cancelled. Cancelling a draft, completed, failed or already-cancelled campaign returns 422 naming its status. A draft never sends anyway, so there is nothing to stop.
Every recipient still pending becomes failed with the error Cancelled by user. failed is recounted and status becomes cancelled. A recipient already sending finishes its one attempt. Messages Meta has already accepted cannot be recalled, and they keep receiving delivered and read receipts.
Nothing is reserved up front: credits are only taken when a message is handed to Meta, so cancelled recipients cost nothing. (A recipient whose first attempt could not reach Meta and was waiting for its retry is refunded.) No body is needed.
Parameter
In
Type
Description
idrequired
path
integer
Campaign id: data.id from POST /send or POST /campaigns. Any campaign of this project works, including ones created in the dashboard.
BASH
curl -s -X POST https://everysecond.ai/api/v1/whatsapp/campaigns/88/cancel \
-H "Authorization: Bearer $EVERYSECOND_KEY"
Each part of GET /templates/{id}/schema tells you which recipient field to fill. Every required field is checked for every recipient before anything is stored. One gap fails the whole request with a details line naming the recipient and the field.
Body variables: each variables[].key ("1", "2"…) becomes a key in the recipient's variables object, e.g. {"1": "Ahmed", "2": "RC-10293"}. Every placeholder needs a non-empty value. Numbers are converted to text and extra keys are ignored. Use an object, not a JSON array: an array is read from index 0, so every value would shift by one. Meta rejects values that contain line breaks, tabs or long runs of spaces, so keep each value on one line, and join multi-line content such as order items with commas.
recipients[].name is only a label in reports and never fills a placeholder. If the template greets the customer, pass the name in variables too.
Media header (header.media_required: true, header type IMAGE, VIDEO or DOCUMENT): each recipient needs header_media_url, a valid URL up to 2048 characters. When the message is sent, EverySecond downloads the file (30-second timeout) and uploads it to the sending number's WhatsApp media store, then sends it by media id. The URL only has to be reachable from EverySecond, not from Meta. Pre-signed URLs work as long as they are still valid when the message goes out. Sends are paced one per second, so the last recipient of a large campaign is sent well after the first.
Each upload is cached for 12 hours per sending number and URL, so a campaign that uses one image URL for everyone uploads it once. Use a new URL if you change the file. Serve the file with the right Content-Type: JPEG or PNG for IMAGE, MP4 or 3GPP for VIDEO, PDF for DOCUMENT. Meta's own size limits apply (roughly 5 MB for images, 16 MB for video and 100 MB for documents). If the download or upload fails, it is retried 3 times, 30 seconds apart, and then the recipient fails with the reason in error.
Text header (header.type: "TEXT"): a header without placeholders is sent exactly as approved. A header with one (Order {{1}}) is listed under header.variables, and each recipient passes its value in header_variables, keyed like variables: {"1": "RC-10293"}. A missing value fails the request with Recipient #n: header_variables.1 is required (the {{1}} in the template's text header).
Dynamic URL buttons: every URL button whose link ends in {{1}} is listed in the schema's buttons, with its index, and every one of them needs a value per recipient. With one such button, pass button_url. With two (Meta allows two URL buttons), pass button_urls keyed by index, e.g. {"1": "AB12", "2": "INV-9"}; button_url keeps filling the first one, so older payloads still work. For each value you can send the part that replaces {{1}} or the whole link: a value that starts with that button's fixed prefix is trimmed to the suffix, so for https://shop.example/track/{{1}} both AB12 and https://shop.example/track/AB12 send AB12. variables.button_url is accepted as well. Quick-reply buttons and static links need nothing.
Authentication (OTP) templates: the schema always shows one variable, "1", which is the code, with required: false. Leave it out and EverySecond generates a 6-digit code for each recipient (the same way dashboard campaigns do), or pass your own, e.g. {"1": "482913"}. Either way the code is placed in the body and in the template's copy-code button, and the send response lists every recipient's code under data.otp_codes (generated: true for the ones EverySecond made) so your system can verify the user. The recipients report repeats it as otp_code.
Numbers are stored and sent as international digits without +. Every phone goes through these steps, and the same steps run whether you post +20 120 725 6335, 00201207256335 or 01207256335.
1. Everything except digits is removed: spaces, dashes, brackets and +.
2. A number written with +, or starting with 00, is already international and is kept as it is, without the 00. country_code is ignored for these numbers.
3. For any other number, the dial code is the recipient's country_code if it is a real calling code ("44", "+44" and "0044" all work). Otherwise it is the country code of the sending number, read from its display_phone_number (+20 10 … gives 20).
4. Leading zeros are removed. If the rest already starts with that dial code it is kept (201207256335 stays 201207256335). If not, the dial code is added in front (01207256335 becomes 201207256335).
5. The result is checked offline with libphonenumber against that country's real number lengths and formats. A number that fails returns 422 with a line like Recipient #2 (12345): Too short for country code +20., or ends up in skipped if you set skip_invalid_phones. Other reasons are Invalid length for country code +N, Not a valid number for country code +N, Unknown country code and Could not parse this number.
Watch out: a foreign number written without + or 00 gets the sender's country code added. Sent from an Egyptian number, 447911123456 becomes 20447911123456 and is rejected. For lists with several countries, send numbers as +…, or set country_code for each recipient.
recipients[].phone in GET /campaigns/{id}/recipients shows the normalised form, so you can match it against your own records.
Sending number +20 10 … (Egypt)
you send stored / sent as
01207256335 201207256335 local, gets the sender's 20
1207256335 201207256335 local without the leading 0
201207256335 201207256335 already starts with 20
+44 7911 123456 447911123456 explicit +
0044 7911 123456 447911123456 00 prefix
07911123456 + "country_code": "44" 447911123456 per-recipient country
447911123456 20447911123456 -> rejected (send +44… instead)
Template messages are paid from the WhatsApp credit wallet of the account that owns the project. One wallet serves all of that owner's projects. Each purchase or grant is a separate lot with its own expiry date. Spending uses the lot that expires soonest first, and the balance only counts lots that have not expired.
The price of one message depends on the template category (MARKETING, UTILITY or AUTHENTICATION; any other category is priced as UTILITY). The platform sets these prices. They appear as credits_per_message on GET /templates and on the schema, and as cost_per_message on GET /credits. A price of 0 means free.
Before sending: POST /send and POST /campaigns/{id}/send compare credits_per_message × total_recipients with the balance. If the balance is short they return 402 with details.credits_required and details.credits_balance, and nothing is queued. POST /campaigns does not check credits.
While sending: nothing is reserved up front. Credits are taken one message at a time, when the send job hands that message to Meta, and each recipient is charged at most once, however often its job is retried or duplicated.
Refunds are automatic and happen at most once per message: when Meta refuses the send outright; when the outcome is unknown (send_status_unknown, see Delivery statuses); when Meta could not be reached at all; and when Meta accepted a message but later reports it as failed in a delivery receipt, because Meta bills on delivery. The recipients report shows credits: 0 and refunded_at for a refunded row.
If the balance runs out partway through, for example because another campaign spent it, the remaining recipients fail with Insufficient WhatsApp credits. Top up, then send those rows again in a new campaign.
Template messages do not count against the plan's monthly chat-message quota.
402 Payment Required
{
"error": "Not enough WhatsApp credits: this campaign needs 500 credit(s) for 250 recipient(s) but the balance is 40.",
"details": { "credits_required": 500, "credits_balance": 40 }
}
Recipient status (in GET /campaigns/{id}/recipients):
pending: stored and waiting for its send job. Jobs run one second apart.
sending: a send job has claimed the recipient and the request to Meta is in flight. Only one job can claim a recipient, so it is never sent twice by two workers. It normally lasts a few seconds.
sent: Meta accepted the message. wa_message_id and sent_at are set, and the credits have been charged.
delivered: Meta's receipt says the message reached the phone.
read: the recipient opened it. A read receipt also counts the message as delivered.
failed: see error. This happens when Meta refused the send, when a later Meta receipt reported a failure (even after sent or delivered), when the outcome is unknown, when Meta could not be reached after 3 tries, when the campaign was cancelled, when credits ran out, or when the sending number was no longer connected. Whatever a failed recipient was charged goes back to the wallet.
Retries: a send is retried automatically (3 tries, 30 seconds apart) only when the request provably never reached Meta: the host could not be resolved, the connection was refused, or connecting timed out. Once the request may have arrived, for example when Meta's answer timed out or the connection dropped mid-response, it is never resent, so the customer cannot get the message twice. The recipient fails with an error that starts with send_status_unknown and is refunded. It may or may not have reached the phone; check before sending it again.
Campaign status (in GET /campaigns/{id}):
draft: created with POST /campaigns and not sent yet.
queued: the send jobs are scheduled but the first message has not gone out yet.
processing: the first message has started going out and the rest follow about one per second. Cancel works in this status and in queued.
completed: every recipient has been tried and at least one was sent. delivered and read keep updating afterwards.
failed: every recipient failed, whatever the path: refused at send, unknown outcome, out of credits, or a later failure receipt that took away the last delivered message (a completed campaign can still turn failed that way).
cancelled: stopped with POST /campaigns/{id}/cancel.
Receipts arrive from Meta seconds to minutes after the send, and they only move a recipient forward: sent to delivered to read. A failure receipt overrides any state. EverySecond does not call you when this happens: poll GET /campaigns/{id} every 10–30 seconds while progress_percent is below 100, then fetch the failures once from the recipients endpoint.
The agent never answers the first reply to a template you sent. After a campaign, a POST /send or an order-status template, the customer’s first message back to that number — text, voice note, photo, or a tap on a quick-reply button — is recorded in the conversation and shown in Tracking and the inbox as “Reply to campaign — not answered by the agent”, for your team to pick up. From the customer’s second message on, the agent answers as usual, unless the chat is paused because a person took it over (a reply from the inbox or the WhatsApp Business app, or “Hand off”).
Window: only the customer’s next message within 7 days of the send, to the number it was sent from, counts as a reply to it; later messages are answered. A newer send starts the rule again.
Not affected: authentication (OTP) templates — a one-time code invites no reply.
The unanswered message does not count toward the plan’s message allowance. It still creates or updates the lead and fires message.received; no message.sent follows it.
API calls: 120 requests per minute per API key. The budget is shared with every other /api/v1 call made with the same key (chats, leads, orders, webhooks, agent). Every response after the key check includes X-RateLimit-Limit and X-RateLimit-Remaining. Going over returns 429 with a Retry-After header in seconds.
The rate limit counts calls, not messages. One POST /send can carry up to 5000 recipients, so put contacts in one call instead of making one call per contact.
Send pacing: within a campaign, messages go out about one per second (5000 recipients take about 80 minutes). Meta's own messaging limits and quality rating for the sending number still apply, and anything Meta refuses shows up as that recipient's error.
Request limits: recipients 1–5000 per campaign; name 255 characters; phone 30; country_code 5; recipient name 255; header_media_url 2048 (must be a valid URL); button_url 2048.
Reports: GET /campaigns/{id}/recipients returns 100 rows per page by default and up to 500 with per_page.
Media: the file is downloaded with a 30-second timeout, and Meta's per-type size limits apply (see Template variables, media and buttons).
There is no Idempotency-Key header. Every POST /send and POST /campaigns creates a new campaign. If a /send times out on your side and you blindly retry it, every recipient can get the message twice.
To retry safely, split creating from sending. Create a draft with POST /campaigns and store data.id, then call POST /campaigns/{id}/send. You can repeat the send step as often as you like: once the campaign has left draft, another send returns 422 Campaign {id} is already "queued"… and nothing is queued again. If the create call itself times out, just create again, because an orphan draft never sends.
A 422 never creates anything, so fix the rows named in details and resend the whole payload. A 402 on /send also leaves nothing behind. A 402 on /campaigns/{id}/send keeps the draft, so you can top up and send the same id.
Cancelling is not repeatable either: a second POST /campaigns/{id}/cancel returns 422 because the campaign is already cancelled. If the error (or GET /campaigns/{id}) says cancelled, the earlier call worked.
On EverySecond's side every recipient is sent and charged at most once: the send job claims the recipient first, retries only when the request never reached Meta, and turns any uncertain outcome into a refunded send_status_unknown failure instead of a second message.
Retry-safe send
# 1) Create the draft once and keep its id with your own record.
ID=$(curl -s -X POST https://everysecond.ai/api/v1/whatsapp/campaigns \
-H "Authorization: Bearer $EVERYSECOND_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Order RC-10293","template_id":19,"recipients":[{"phone":"01207256335","variables":{"1":"Ahmed","2":"RC-10293","3":"1450 EGP"},"header_media_url":"https://cdn.example.com/orders/RC-10293.jpg","button_url":"RC-10293"}]}' \
| jq -r '.data.id')
# 2) Send it. Safe to repeat: a second call answers 422 "already queued".
curl -s -X POST https://everysecond.ai/api/v1/whatsapp/campaigns/$ID/send \
-H "Authorization: Bearer $EVERYSECOND_KEY"
Register https endpoints that EverySecond calls the moment something happens — a new lead, a lead scored or flagged, a knowledge change, a channel connected, disconnected or needing a new login, every message in and out, a pause when a person takes a chat over, comments and their replies, and (with the Orders module) every cart and order event.
These calls manage the same endpoints as the Webhooks tab of the Integration screen, so a website can register its own receiver during setup and rotate the secret from code. Webhook management is available to every agent, with or without the Orders module.
This agent’s endpoints, plus every event name you can subscribe to.
Secrets are never listed. meta.available_events is the complete list this build emits (orders, carts, leads, agent and channels); meta.signature_header names the signature header.
Register an https URL to receive events. The signing secret is returned once, here.
Store data.secret now: it verifies the X-EverySecond-Signature header and is not returned again except by POST /api/v1/webhooks/{id}/rotate. Every endpoint answer is wrapped in data (create also returns a note).
The URL must use https:// and must not point at localhost or a private or reserved network address (checked again before every delivery).
Unknown event names are dropped silently; if none of the names exist the call answers 422. Subscribing to an order event on an agent without the Orders module is harmless — it never fires.
Parameter
In
Type
Description
urlrequired
body
string
The https endpoint on your side, up to 2048 characters.
name
body
string
A label, up to 150 characters.
eventsrequired
body
array<string>
At least one event name, e.g. ["lead.created","lead.updated"]. See GET /api/v1/webhooks for the list.
{
"error": "Validation failed.",
"details": [
"events is required. Call GET /api/v1/webhooks to see the available event names."
]
}
Response
{
"data": {
"id": 4,
"project_id": 812,
"name": "CRM sync",
"url": "https://crm.kosharyelnil.com/hooks/everysecond",
"events": [
"lead.created",
"lead.updated",
"lead.flagged"
],
"is_active": true,
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"last_error": null,
"disabled_reason": null,
"previous_secret_expires_at": null,
"created_at": "2026-10-07T11:50:02+00:00",
"secret": "whsec_9fK2mQ7xLp4Rt8Vw1Zc6Bn3Hs5Jd0Ga2Ye7Ui4Ko9Pl1Mq8X"
},
"note": "Store the secret now — it verifies the X-EverySecond-Signature header and is not returned again except through rotate."
}
Errors (3)
Status
Code
When
422
Validation failed.
url or events missing, or events not an array — details lists the messages.
422
Bad URL
That is not a valid URL., The URL must use https:// so the event data is encrypted in transit., Webhooks cannot be sent to localhost., or That host resolves to a private network address, which is not allowed.
Issue a new signing secret and return it once; the old one keeps signing for 24 hours.
The replaced secret stays valid for 24 hours, until data.previous_secret_expires_at. In that window every delivery — new events, retries and redeliveries of older ones — is signed with both secrets: the signature header carries one v1 per secret (see “Verifying the signature”), so a receiver that has not deployed the new secret yet keeps verifying. Afterwards only the new secret signs.
Send immediate: true when the old secret leaked: it is dropped at once and previous_secret_expires_at is null. Rotating again inside the 24 hours retires the older secret for good — only the most recent previous secret is kept.
Your receiver decides what it accepts: remove the old secret from its configuration as soon as the new one is deployed (straight away if it leaked). The Rotate secret button on the Integration screen behaves the same way, with the 24-hour overlap.
Parameter
In
Type
Description
idrequired
path
integer
The endpoint id.
immediate
body
boolean
Drop the old secret at once instead of keeping it for 24 hours. Default false.
BASH
curl -X POST https://everysecond.ai/api/v1/webhooks/4/rotate \
-H "Authorization: Bearer $EVERYSECOND_KEY"
# the old secret leaked — stop signing with it right away:
curl -X POST https://everysecond.ai/api/v1/webhooks/4/rotate \
-H "Authorization: Bearer $EVERYSECOND_KEY" \
-H "Content-Type: application/json" \
-d '{ "immediate": true }'
immediate: true
{
"data": {
"id": 4,
"project_id": 812,
"name": "CRM sync",
"url": "https://crm.kosharyelnil.com/hooks/everysecond",
"events": [
"lead.created",
"lead.updated",
"lead.flagged"
],
"is_active": true,
"consecutive_failures": 0,
"last_success_at": "2026-10-07T10:58:03+00:00",
"last_failure_at": null,
"last_error": null,
"disabled_reason": null,
"previous_secret_expires_at": null,
"created_at": "2026-09-30T08:12:44+00:00",
"secret": "whsec_Vb7Nq2Kx9Lm4Pz8Rt1Wc6Yd3Hf5Js0Ga2Ue7Io4Kp9Ml1Xq8Zr3Tv6Bn5Cw"
},
"note": "The previous secret was dropped: every delivery from now on is signed with the new secret only."
}
Response
{
"data": {
"id": 4,
"project_id": 812,
"name": "CRM sync",
"url": "https://crm.kosharyelnil.com/hooks/everysecond",
"events": [
"lead.created",
"lead.updated",
"lead.flagged"
],
"is_active": true,
"consecutive_failures": 0,
"last_success_at": "2026-10-07T10:58:03+00:00",
"last_failure_at": null,
"last_error": null,
"disabled_reason": null,
"previous_secret_expires_at": "2026-10-08T12:04:31+00:00",
"created_at": "2026-09-30T08:12:44+00:00",
"secret": "whsec_Qm4Zt8Lp2Xr6Vw0Bn3Hs7Jd1Ga5Ye9Ui2Ko6Pl4Mq8Xc3Rt7Vw1Zc5Bn9H"
},
"note": "Until previous_secret_expires_at every delivery carries two v1 signatures, one per secret — deploy the new secret before then; a receiver accepts a request when any v1 matches."
}
Errors (2)
Status
Code
When
404
Not found
Webhook endpoint 4 was not found for project "Nour".
422
Validation failed.
immediate is not a boolean — details lists the message.
Queue a real, signed webhook.test delivery to this endpoint, whatever it is subscribed to.
Returns the queued delivery (usually still pending); read its outcome with GET /api/v1/webhooks/{id}/deliveries. The test goes through the same signing, retries and failure counting as real events. See the “Test event” guide for the body.
status is pending (queued or waiting for a retry — see next_attempt_at), delivered or failed (gave up). attempts counts HTTP attempts made for that delivery; response_body is cut to 300 characters.
Parameter
In
Type
Description
idrequired
path
integer
The endpoint id.
limit
query
integer
How many deliveries, default 50; values outside 1–200 are clamped, not rejected.
Send an earlier delivery again, byte for byte, as a new delivery.
The copy keeps the same event_id and the identical body (including the envelope’s original created_at), gets a new delivery id, and is signed at send time with the endpoint’s current secret (plus the previous one during a rotation’s 24-hour overlap). Works for delivered and failed deliveries alike.
If the endpoint has been disabled meanwhile, the copy fails at once with Endpoint is disabled. — re-enable it first.
Every delivery of every event is a POST with a JSON body of the same shape: id (the event id, a UUID), event, created_at (when the event happened), project (id, name — the agent) and data (the event-specific object documented in the event catalogue).
Headers: Content-Type: application/json, User-Agent: EverySecond-Webhooks/1.0, X-EverySecond-Event (the event name), X-EverySecond-Event-Id (the same as id in the body), X-EverySecond-Delivery (the delivery id, as in the delivery log) and X-EverySecond-Signature (see “Verifying the signature” — it can carry more than one v1).
X-EverySecond-Event-Id is shared by every endpoint that receives the event and stays the same on retries and on manual redeliveries — deduplicate on it. X-EverySecond-Delivery stays the same across the automatic retries of one delivery; a redelivery or a test is a new delivery with a new id.
The X-EverySecond-Signature header looks like t=<unix seconds>,v1=<hex>. Each v1 is the HMAC-SHA256, hex-encoded, of the string <t>.<raw request body>, keyed with an endpoint secret — the whole whsec_… string as returned on create or rotate.
There can be more than one v1. For 24 hours after a rotation (POST /api/v1/webhooks/{id}/rotate or the Integration screen) every delivery is signed with the new and the previous secret: t=<unix>,v1=<new>,v1=<old>,v1=<new> — the current signature first and last, so a receiver still reading a single v1 keeps working. Accept the request when any v1 matches the secret you hold, and do not rely on the position or the number of v1 values; the samples below do exactly that. After the 24 hours, or after a rotate with immediate: true, there is one v1 again.
Hash the raw body exactly as received, not a re-encoded version of the parsed JSON: re-serialising changes spacing, escaping and number formats and the signature will never match.
Compare in constant time, and reject a t more than about 5 minutes from your clock: the timestamp is inside the HMAC, so a captured request cannot be replayed later. t is fresh on every attempt, including retries and redeliveries.
PHP
<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_EVERYSECOND_SIGNATURE'] ?? '';
// t=<unix>,v1=<hex>[,v1=<hex>...] — one v1 per secret while a rotation overlaps
$t = '';
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', $part, 2), 2, '');
if ($key === 't') { $t = $value; }
if ($key === 'v1') { $signatures[] = $value; }
}
$secret = getenv('EVERYSECOND_WEBHOOK_SECRET'); // the whole whsec_... string
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
$valid = false;
foreach ($signatures as $signature) {
$valid = hash_equals($expected, $signature) || $valid; // accept if ANY v1 matches
}
if (!$valid || abs(time() - (int) $t) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
// Deduplicate on $event['id'], queue the work, then answer quickly:
http_response_code(200);
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
// The raw body is required: use express.raw for this route, before any JSON parser.
app.post('/hooks/everysecond', express.raw({ type: 'application/json' }), (req, res) => {
// t=<unix>,v1=<hex>[,v1=<hex>...] — one v1 per secret while a rotation overlaps
const pairs = (req.get('X-EverySecond-Signature') || '').split(',').map((p) => p.split('='));
const t = (pairs.find(([key]) => key === 't') || [])[1] || '';
const signatures = pairs.filter(([key]) => key === 'v1').map(([, value]) => Buffer.from(value || ''));
const expected = Buffer.from(
crypto
.createHmac('sha256', process.env.EVERYSECOND_WEBHOOK_SECRET) // the whole whsec_... string
.update(Buffer.concat([Buffer.from(t + '.'), req.body]))
.digest('hex')
);
// Accept if ANY v1 matches.
const valid = signatures.some((given) => given.length === expected.length && crypto.timingSafeEqual(expected, given));
if (!valid || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id, hand off to a queue, then:
res.sendStatus(200);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["EVERYSECOND_WEBHOOK_SECRET"].encode() # the whole whsec_... string
@app.post("/hooks/everysecond")
def everysecond_hook():
raw = request.get_data() # the exact bytes, before any JSON parsing
header = request.headers.get("X-EverySecond-Signature", "")
# t=<unix>,v1=<hex>[,v1=<hex>...] — one v1 per secret while a rotation overlaps
pairs = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((value for key, value in pairs if key == "t"), "")
signatures = [value for key, value in pairs if key == "v1"]
expected = hmac.new(SECRET, t.encode() + b"." + raw, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, s) for s in signatures): # accept if ANY v1 matches
return "", 400
if abs(time.time() - int(t or "0")) > 300:
return "", 400
event = request.get_json()
# Deduplicate on event["id"], queue the work, then answer quickly
return "", 200
Answer with any 2xx status within 10 seconds (5 seconds to connect). Anything else — another status, a timeout, a connection error — is a failed attempt. Acknowledge first and do the work afterwards.
A failed attempt is retried after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours: six attempts in all, on the same delivery id. While it waits the delivery is pending with next_attempt_at set; after the sixth failure it is failed.
Only a delivery that has used up all its attempts counts against the endpoint. After 20 such failed deliveries in a row the endpoint switches itself off (is_active: false, disabled_reason: "20 consecutive deliveries failed. Fix the endpoint and re-enable it."); a single successful delivery resets the streak. Re-enable with PATCH /api/v1/webhooks/{id} and { "is_active": true }, then redeliver what you missed.
The destination is re-checked before every attempt: a host that now resolves to a private address fails that attempt. Deliveries queued for an endpoint that is disabled before they are sent are marked failed with Endpoint is disabled.
Order is not guaranteed — lead.updated can arrive before a retried lead.created. Upsert on the object id and use the timestamps.
Read the delivery log with GET /api/v1/webhooks/{id}/deliveries and resend any entry with POST /api/v1/webhooks/deliveries/{id}/redeliver. A redelivery carries the original body and event id, so a receiver that deduplicates on X-EverySecond-Event-Id processes it at most once.
For leads, the leads feed (GET /api/v1/leads?since=…) is the gap-free backfill: it serves the same lead object the events carry.
POST /api/v1/webhooks/{id}/test sends webhook.test to that endpoint whether or not it is subscribed to anything, with a real signature, so you can point your handler at something before a customer ever writes. Its data holds a message and a sample_order with the shape of data.order on the order events, filled with dummy values (ids 0).
Training Yard activity never produces deliveries — the test event is the way to exercise a receiver.
Every event below arrives in the same signed envelope — { id, event, created_at, project: {id, name}, data } — where data is shown per event. See the Webhooks section for registering endpoints, verifying the signature and replaying deliveries.
Leads
lead.createdA lead is created — the first message from a person the agent has not spoken to before, on any channel (WhatsApp, Messenger, Instagram, website widget, chat API), or a lead import.data
lead.updatedA field a CRM mirrors changed on a lead — name, phone, email, score, score reason, summary, inquiry, CRM status or assignee (email, status and assignee are set on the Leads screen); changes names the fields that moved by their place in the lead object: contact.name, contact.phone (with contact.phone_e164), contact.email, qualification.score, qualification.score_reason, qualification.summary, qualification.inquiry, crm.status, crm.assigned_to.data
lead.flaggedOne or more flags were newly attached to a lead; flags lists only the new names. source says who attached them: ai (the agent judged it), keyword (a message matched — a flag whose assigned_by is keywords) or manual (a person ticked it on the Leads screen, whatever the flag’s assigned_by). Re-saving the same flags, or confirming one the agent already set, fires nothing; imported flags do not fire it.data
agent.updatedThe agent’s settings changed — through the API (PATCH /api/v1/agent, POST /api/v1/agent/instructions) or by hand in the dashboard, the app or the setup portal; source is "api" or "dashboard". changes names the agent-object fields whose value actually changed (name, status, industry_id, identity, tone, instructions, reply_verbosity, disable_emojis, comment_reply, channels.facebook, channels.instagram, channels.whatsapp, channels.widget); a save that changes none of them fires nothing.data
{
"agent": {
"id": 812,
"name": "Nour",
"status": "live",
"industry_id": 9,
"identity": "Nour, the ordering assistant of Koshary El Nil in Maadi",
"tone": "Professional and precise",
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu."
],
"reply_verbosity": "short",
"disable_emojis": false,
"comment_reply": "شكراً لتعليقك! بعتنالك التفاصيل في رسالة خاصة.",
"human_takeover_hours": 6,
"reply_dialect": "auto",
"ticket_notice": "agent",
"phone_request": "once",
"business_country": null,
"channels": {
"facebook": true,
"instagram": true,
"whatsapp": false,
"widget": true
},
"updated_at": "2026-10-07T11:40:09+00:00"
},
"changes": [
"tone",
"reply_verbosity"
],
"source": "api"
}
knowledge.addedA document was added to the agent’s knowledge — through POST /api/v1/agent/knowledge (source: "api") or in the dashboard: Knowledge Sources, the setup portal, an approved Training Yard answer (source: "dashboard"). document is text { id, type: "text", title, chars }, file { id, type: "file", name } or page { id, type: "url", source }; title is null for text added without one (the dashboard has no title field).data
connection.requestedAn integration asked for a channel to be connected (POST /api/v1/agent/connections/{id}); connection.connect_url is the page the owner must open. source is always "api": in the dashboard the owner connects a channel directly, which fires channel.connected instead.data
channel.connectedThe owner finished connecting a channel — channel.channel is facebook, instagram (the Instagram account linked to the page fires its own event) or whatsapp; channel.id is the page id, Instagram account id or WhatsApp phone-number id. reconnect is true when that page, account or number was connected to the agent before (a re-login, or a number Meta had disconnected). Any pending connection request for it is closed as completed. For WhatsApp it fires whichever way the onboarding completes (right away in the browser, or minutes later when Meta confirms it), once per connection: re-saving a number that is still connected sends nothing.data
{
"channel": {
"channel": "whatsapp",
"id": "109876543210987",
"name": "Koshary El Nil Maadi",
"display_phone_number": "+20 100 000 0001",
"agent": {
"id": 812,
"name": "Nour",
"status": "live",
"industry_id": 9,
"identity": "Nour, the ordering assistant of Koshary El Nil in Maadi",
"tone": "Friendly Egyptian Arabic, short and warm",
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu."
],
"reply_verbosity": "balanced",
"disable_emojis": false,
"comment_reply": "شكراً لتعليقك! بعتنالك التفاصيل في رسالة خاصة.",
"human_takeover_hours": 6,
"reply_dialect": "auto",
"ticket_notice": "agent",
"phone_request": "once",
"business_country": null,
"channels": {
"facebook": true,
"instagram": true,
"whatsapp": true,
"widget": true
},
"updated_at": "2026-10-07T12:02:51+00:00"
}
},
"reconnect": false
}
channel.disconnectedA channel stopped working for the agent. reason: "owner_disconnected" — the owner removed it in the dashboard or the app (initiated_by: "owner", source: "dashboard"; removing a Facebook page fires for its Instagram account too). reason: "meta_disconnected" — Meta cut a WhatsApp number off (initiated_by: "meta", meta_reason as Meta gives it, e.g. PRIMARY_INACTIVITY, PARTNER_REMOVED; detected_by: "meta_webhook", or "health_check" when the daily probe found the account gone — meta_reason: "GRAPH_OBJECT_UNAVAILABLE"). A Facebook page whose token Meta stops accepting is reported as channel.needs_reauth.data
{
"channel": {
"channel": "whatsapp",
"id": "109876543210987",
"name": "Koshary El Nil Maadi",
"display_phone_number": "+20 100 000 0001",
"agent": {
"id": 812,
"name": "Nour",
"status": "live",
"industry_id": 9,
"identity": "Nour, the ordering assistant of Koshary El Nil in Maadi",
"tone": "Friendly Egyptian Arabic, short and warm",
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu."
],
"reply_verbosity": "balanced",
"disable_emojis": false,
"comment_reply": "شكراً لتعليقك! بعتنالك التفاصيل في رسالة خاصة.",
"human_takeover_hours": 6,
"reply_dialect": "auto",
"ticket_notice": "agent",
"phone_request": "once",
"business_country": null,
"channels": {
"facebook": true,
"instagram": true,
"whatsapp": false,
"widget": true
},
"updated_at": "2026-10-07T09:14:22+00:00"
}
},
"reason": "meta_disconnected",
"initiated_by": "meta",
"meta_reason": "PRIMARY_INACTIVITY",
"detected_by": "meta_webhook",
"source": null
}
channel.restoredA channel came back without a new login: the daily probe reaches a WhatsApp account Meta had cut off (detected_by: "health_check", previous_reason = the old meta_reason), or a send works again after channel.needs_reauth (detected_by: "send", previous_reason: "token_invalid"). An owner reconnecting through the login fires channel.connected with reconnect: true instead.data
{
"channel": {
"channel": "whatsapp",
"id": "109876543210987",
"name": "Koshary El Nil Maadi",
"display_phone_number": "+20 100 000 0001",
"agent": {
"id": 812,
"name": "Nour",
"status": "live",
"industry_id": 9,
"identity": "Nour, the ordering assistant of Koshary El Nil in Maadi",
"tone": "Friendly Egyptian Arabic, short and warm",
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu."
],
"reply_verbosity": "balanced",
"disable_emojis": false,
"comment_reply": "شكراً لتعليقك! بعتنالك التفاصيل في رسالة خاصة.",
"human_takeover_hours": 6,
"reply_dialect": "auto",
"ticket_notice": "agent",
"phone_request": "once",
"business_country": null,
"channels": {
"facebook": true,
"instagram": true,
"whatsapp": false,
"widget": true
},
"updated_at": "2026-10-07T09:14:22+00:00"
}
},
"previous_reason": "PRIMARY_INACTIVITY",
"detected_by": "health_check"
}
channel.needs_reauthMeta rejected the channel’s access token (Graph error 190 or 102) while the agent replied, while a refused reply was being redelivered, or on a WhatsApp campaign send — and refreshing the page token did not help. The owner must reconnect the channel. Fires once per channel until a send works again (channel.restored) or the owner reconnects (channel.connected). detected_by: reply, redelivery or campaign.data
{
"channel": {
"channel": "facebook",
"id": "104857392011873",
"name": "Koshary El Nil",
"display_phone_number": null,
"agent": {
"id": 812,
"name": "Nour",
"status": "live",
"industry_id": 9,
"identity": "Nour, the ordering assistant of Koshary El Nil in Maadi",
"tone": "Friendly Egyptian Arabic, short and warm",
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu."
],
"reply_verbosity": "balanced",
"disable_emojis": false,
"comment_reply": "شكراً لتعليقك! بعتنالك التفاصيل في رسالة خاصة.",
"human_takeover_hours": 6,
"reply_dialect": "auto",
"ticket_notice": "agent",
"phone_request": "once",
"business_country": null,
"channels": {
"facebook": true,
"instagram": true,
"whatsapp": false,
"widget": true
},
"updated_at": "2026-10-07T09:14:22+00:00"
}
},
"error": {
"code": 190,
"subcode": 460,
"type": "OAuthException",
"message": "Error validating access token: The session has been invalidated because the user changed their password."
},
"detected_by": "reply"
}
channel.quality_changedA WhatsApp number’s quality flag or messaging limit changed (Meta’s phone_number_quality_update). event as Meta sends it — FLAGGED (quality dropped, at risk), UNFLAGGED, DOWNGRADE, UPGRADE, ONBOARDING…; current_limit / previous_limit are tiers such as TIER_250, TIER_1K, TIER_10K, TIER_100K, TIER_UNLIMITED (previous_limit and max_daily_conversations are null when Meta leaves them out).data
{
"channel": {
"channel": "whatsapp",
"id": "109876543210987",
"name": "Koshary El Nil Maadi",
"display_phone_number": "+20 100 000 0001",
"agent": {
"id": 812,
"name": "Nour",
"status": "live",
"industry_id": 9,
"identity": "Nour, the ordering assistant of Koshary El Nil in Maadi",
"tone": "Friendly Egyptian Arabic, short and warm",
"instructions": [
"Confirm the delivery area before quoting a delivery fee.",
"Never quote a price that is not on the menu."
],
"reply_verbosity": "balanced",
"disable_emojis": false,
"comment_reply": "شكراً لتعليقك! بعتنالك التفاصيل في رسالة خاصة.",
"human_takeover_hours": 6,
"reply_dialect": "auto",
"ticket_notice": "agent",
"phone_request": "once",
"business_country": null,
"channels": {
"facebook": true,
"instagram": true,
"whatsapp": false,
"widget": true
},
"updated_at": "2026-10-07T09:14:22+00:00"
}
},
"event": "DOWNGRADE",
"current_limit": "TIER_1K",
"previous_limit": "TIER_10K",
"max_daily_conversations": null
}
WhatsApp templates
template.status_changedMeta’s verdict on a WhatsApp template changed on one number (message_template_status_update): status is approved, rejected, paused, disabled or pending (an appeal); reason is Meta’s reason for rejected / paused / disabled (else null). Approval is per number, so one template can fire once per number it was submitted to. Meta repeating the same verdict fires nothing.data
message.receivedA customer sent a message on WhatsApp, Messenger, Instagram or the chat API (the ordering widget rides the chat API: conversation.channel: "api" with conversation.chat). message.media lists photos, videos, voice notes and files ({type, pending: true} while still downloading — read them later from the conversation). referral describes the ad or post the customer came from (Click-to-Messenger / Click-to-WhatsApp), on the message that came from it, else null. lead_id is null for a brand-new customer’s first message — lead.created follows as the agent processes it. High volume: one event per message. Training Yard messages never fire.data
message.sentA reply reached the customer. author: agent (the AI agent — replies, product photos, follow-ups), human (a teammate replying from Tracking or the app inbox), human_outside (a person replying in Meta Business Suite or the Instagram / WhatsApp Business app — seen through Meta’s echo) or system (order-status updates). A reply Meta refused at first fires once redelivery gets it through; one that never gets through does not fire. Campaign template sends are not replies (the WhatsApp outbound API reports their delivery). Same message and conversation shapes as message.received.data
conversation.pausedThe agent stepped back in one Messenger, Instagram or WhatsApp conversation: a person replied outside EverySecond (reason: "outside_reply"), a teammate replied from the inbox ("inbox_reply"), or “Hand off” was pressed ("manual", automatic: false). pause.until is when the agent answers again by itself — no event marks that moment. extended: true = a shorter pause was already running and now lasts longer. label is the reason in words, as the dashboard shows it.data
{
"conversation": {
"channel": "facebook",
"channel_id": "104857392011873",
"customer": {
"id": "6723451198765432",
"name": "Mona Adel"
},
"lead_id": 488
},
"pause": {
"reason": "inbox_reply",
"label": {
"ar": "Mona رد من صندوق الرسايل — الوكيل وقف 6 ساعات",
"en": "Mona replied from the inbox — the agent paused for 6h"
},
"by": "Mona",
"hours": 6,
"automatic": true,
"until": "2026-10-08T16:20:00+00:00",
"extended": false
}
}
conversation.resumedA person switched a running pause off before it ended (Tracking or the app inbox). paused_reason is why the agent had paused.data
comment.receivedA customer commented on a Facebook or Instagram post (live, from Meta’s webhook — the Comments screen’s “Sync from Facebook” backfill never fires it). comment.id is EverySecond’s id, meta_comment_id Meta’s; channel_id is the page or Instagram account id.data
comment.repliedA reply to a comment went out — kind: "public" (under the comment) or "private" (a DM to the commenter). author: agent (the agent’s DM or the canned public reply, source: "auto"), human (the Comments screen, source: "manual") or human_outside (typed on Facebook / Instagram itself, source: "native"). Only replies that were delivered fire.data
reservation.cancelledA booking was cancelled — by the customer in chat, staff, or the API (a tour cancellation frees its seats for the waiting list).data
reservation.waitlistedA tourism booking went onto its departure's waiting list — at creation (right after reservation.created) or when moved onto a full departure. reservation.waitlist_position is its place in line.data
reservation.promotedA waiting booking got its seats — automatically when seats freed up (changed_by: system, trigger.reason: cancellation · capacity · moved · resized · reopened, trigger.by: who freed them) or because staff promoted it (changed_by: dashboard, no trigger). It becomes confirmed (pending when the AI made it).data
reservation_resource.updatedOne was changed — change: edited (changes + previous), activated, deactivated, or photos (photos_change: added · removed · reordered; resource.photos is the new set).data
departure.updatedA departure's time, capacity, price, notes or switch changed (change: edited · activated · deactivated; changes + previous). A moved time moves its bookings too (each fires reservation.updated).data
departure.reopenedA full departure has a free seat again (a cancellation, a smaller party, a bigger capacity — after the waiting list took what fit).data
reservation_setup.updatedThe agent's kind of business changed (change: type, previous.type) or a hotel's photo gallery changed (change: hotel_photos, branch_id, photos).data
order.updatedAn existing order changed (items, fulfilment, address or notes in chat or on the dashboard, or tracking_url / merchant_ref through the status API — only when the value really changed); changed names the fields.data
order.confirmedStaff accepted the order — confirmed_by is staff for the dashboard button and api for /confirm or a forward move to status confirmed from your system (a forced step back to confirmed does not send it).data
order.status_changedThe order's status actually moved (pending → confirmed → preparing → …), from any source, including a forced correction from your system; never sent for a repeat of the same status.data
order.cancelledThe order was cancelled and its reserved stock released — cancelled_by is staff (dashboard), ai (the customer in chat) or api (your system).data
order.failedThe customer confirmed an order in chat but it could not be saved — reason is out_of_zone, out_of_stock, invalid (item no longer on the menu), not_orderable, over_limit, rejected_by_merchant or error.data
order.validateSynchronous: before the agent reads a changed basket back (stage quote) and when the customer confirms (stage place), your endpoint is asked and its JSON answer decides price, stock, fee and whether the order goes ahead.data
cart.updatedThe customer's basket changed in any way (by the agent or through your PUT /chats/{id}/cart); carries the full basket plus what was added and removed.data
cart.item_addedItems were added to the basket or a quantity went up; items holds only what was added, with the quantity that moved (each with its line_id).data
product.createdA menu item / store product was added — on the dashboard, through the menu API, by a CSV/XLSX import or a store / feed sync (source: dashboard, api, csv, feed, shopify, salla, easyorders). One event per branch row.data
product.updatedA product changed — changes names the fields (price, offer_price, stock_qty, available, name, image_url, product_url, buy_url…). Stock taken or given back by an order has source: "order". A resync that changes nothing sends nothing; the local copy of the photo is not news.data
product.deletedA product left the catalogue — deleted on the dashboard or through the API, removed from the store, or gone from the feed / file. product holds its last values.data
store.connectedA Shopify / Salla / EasyOrders store, a product feed or a product file was connected (or reconnected) on the Orders screen. Never carries a credential.data
store.disconnectedThe connection ended — reason is owner (disconnected on the dashboard) or uninstalled (the store removed our app). Its products stay unless the owner removed them too.data
store.needs_reauthThe store refused our credentials (token revoked, refresh expired, key deleted) — the owner must connect it again; scheduled syncs stop until then.data
catalog.sync_failedA sync could not finish (store unreachable, credentials refused, a feed that is not a feed, a store that suddenly returned nothing). Nothing was deleted; it is retried in an hour.data
{
"store": {
"id": 14,
"project_id": 130,
"platform": "shopify",
"status": "error",
"shop_domain": "nilewear.myshopify.com",
"store_url": "https://nilewear.example",
"store_name": "Nile Wear",
"external_store_id": null,
"settings": {
"import_orders": true
},
"has": {
"access_token": true,
"refresh_token": false,
"api_key": false,
"webhook_secret": true
},
"token_expires_at": "2026-10-09T09:10:02+00:00",
"webhooks": [
{
"topic": "PRODUCTS_CREATE",
"ok": true
},
{
"topic": "PRODUCTS_UPDATE",
"ok": true
},
{
"topic": "PRODUCTS_DELETE",
"ok": true
},
{
"topic": "APP_UNINSTALLED",
"ok": true
},
{
"topic": "ORDERS_CREATE",
"ok": true
},
{
"topic": "ORDERS_UPDATED",
"ok": true
}
],
"product_count": 1932,
"connected_at": "2026-10-08T09:10:02+00:00",
"last_sync_at": "2026-10-08T09:12:44+00:00",
"last_sync_status": "failed",
"last_sync_counts": {
"created": 1932,
"updated": 0,
"unchanged": 0,
"deleted": 0,
"skipped": 3,
"total": 1935,
"errors": [
"Skipped a product without a name, id or price."
]
},
"last_error": "The store returned no products at all, so nothing was removed. Check the connection's permissions and try again.",
"next_sync_at": "2026-10-09T09:12:44+00:00"
},
"trigger": "schedule",
"error": "The store returned no products at all, so nothing was removed. Check the connection's permissions and try again.",
"counts": {
"created": 0,
"updated": 0,
"unchanged": 0,
"deleted": 0,
"skipped": 0,
"total": 0,
"errors": []
}
}
store.order_receivedAn order placed on the store itself arrived (only when the connection imports orders). order.created fires too, with order.source = the platform; later store updates send the usual order.status_changed / order.cancelled (cancelled_by: "store").data