Developers
API reference · updated 2026-10-08

Build on EverySecond

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
  1. Get a keyDashboard → Integrations → API key (one per agent).
  2. Call the APIEvery request sends Authorization: Bearer <key> (or X-Api-Key).
  3. 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).

Authenticating requests

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"
401
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59

{"error":"Invalid API key."}

Errors

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."
    ]
  }
}

Rate limits

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"}

Conventions

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.

Live data, quotas and the Training Yard

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.

Conversations

Chat API

API key

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 chat

POST/api/v1/chats

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.

ParameterInTypeDescription
external_idbodystringYour own id for this customer (user id, session id, phone). Up to 191 characters. Recommended: it makes the call idempotent.
namebodystringCustomer name, up to 120 characters.
phonebodystringCustomer phone, up to 40 characters. Stored on the chat; the agent still captures the phone the customer says onto the lead.
metadatabodyobjectAny JSON object you want echoed back (plan, page, locale…).
BASH
curl -X POST https://everysecond.ai/api/v1/chats \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "external_id": "user-42", "name": "Mona Adel", "phone": "01001234567", "metadata": { "source": "ios-app" } }'
200 — the chat for user-42 already existed
{
  "chat": {
    "id": 17,
    "external_id": "user-42",
    "name": "Mona Adel",
    "phone": "01001234567",
    "metadata": {
      "source": "ios-app"
    },
    "message_count": 6,
    "last_message_at": "2026-10-07T11:06:40+00:00",
    "created_at": "2026-10-07T11:02:05+00:00"
  },
  "created": false
}
Response
{
  "chat": {
    "id": 17,
    "external_id": "user-42",
    "name": "Mona Adel",
    "phone": "01001234567",
    "metadata": {
      "source": "ios-app"
    },
    "message_count": 0,
    "last_message_at": null,
    "created_at": "2026-10-07T11:02:05+00:00"
  },
  "created": true
}
Errors (2)
StatusCodeWhen
401UnauthorizedMissing or unknown API key.
422ValidationA field is too long, or metadata is not an object.

List chats

GET/api/v1/chats

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_id widget:<visitor_id> and metadata { "source": "widget" }.

ParameterInTypeDescription
per_pagequeryinteger1–100, default 25.
pagequeryintegerPage number, from 1.
external_idquerystringOnly the chat with this external id (up to 191 characters).
BASH
curl "https://everysecond.ai/api/v1/chats?per_page=50&page=1" \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "chats": [
    {
      "id": 17,
      "external_id": "user-42",
      "name": "Mona Adel",
      "phone": "01001234567",
      "metadata": {
        "source": "ios-app"
      },
      "message_count": 6,
      "last_message_at": "2026-10-07T11:06:40+00:00",
      "created_at": "2026-10-07T11:02:05+00:00"
    }
  ],
  "total": 1,
  "per_page": 25,
  "current_page": 1,
  "last_page": 1
}
Errors (1)
StatusCodeWhen
422Validationper_page above 100, or a non-integer page.

Get a chat

GET/api/v1/chats/{id}

One chat, with the lead it has produced so far.

lead is the same lead object as GET /api/v1/leads/{id} and the lead.* webhooks, or null until the customer has said something.

ParameterInTypeDescription
idrequiredpathintegerThe chat id.
BASH
curl https://everysecond.ai/api/v1/chats/17 \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "chat": {
    "id": 17,
    "external_id": "user-42",
    "name": "Mona Adel",
    "phone": "01001234567",
    "metadata": {
      "source": "ios-app"
    },
    "message_count": 6,
    "last_message_at": "2026-10-07T11:06:40+00:00",
    "created_at": "2026-10-07T11:02:05+00:00"
  },
  "lead": {
    "id": 90547,
    "created_at": "2026-10-07T11:02:11+00:00",
    "updated_at": "2026-10-07T11:06:48+00:00",
    "contact": {
      "name": "Mona Adel",
      "first_name": "Mona",
      "last_name": "Adel",
      "phone": "01001234567",
      "phone_e164": "+201001234567",
      "email": null,
      "language": "en",
      "external_id": "api_812_k3x9q2m7v1b4n8c5"
    },
    "attribution": {
      "source": "everysecond",
      "channel": "website",
      "channel_code": "web",
      "agent_id": 812,
      "agent_name": "Nour"
    },
    "crm": {
      "status": "new",
      "assigned_to": null,
      "source": "chat",
      "next_follow_up": null
    },
    "qualification": {
      "score": 38,
      "score_reason": "Asked for prices and gave a delivery address",
      "summary": "Asked whether delivery reaches Maadi and how much it costs.",
      "inquiry": "Delivery to Maadi",
      "flags": []
    },
    "links": {
      "conversation": "https://everysecond.ai/api/v1/leads/90547/conversation",
      "dashboard": "https://everysecond.ai/leads/90547"
    }
  }
}
Errors (1)
StatusCodeWhen
404Not foundNo such chat on this agent (a chat id from another key is a 404).

Close a chat

DELETE/api/v1/chats/{id}

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.

ParameterInTypeDescription
idrequiredpathintegerThe chat id.
BASH
curl -X DELETE https://everysecond.ai/api/v1/chats/17 \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "deleted": true,
  "chat_id": 17
}
Errors (1)
StatusCodeWhen
404Not foundNo such chat on this agent.

Send a message

POST/api/v1/chats/{id}/messages

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.

ParameterInTypeDescription
idrequiredpathintegerThe chat id.
messagebodystringThe customer’s text, up to 4000 characters. Required unless audio is sent; cannot be combined with audio.
audiobodyfileA voice recording (multipart), up to 25 MB: ogg/opus, m4a/aac, mp3, wav or webm. Required unless message is sent.
languagebodystringOptional language hint for the transcription (ar, en…), up to 10 characters. Leave it out and the language is detected.
Text
curl -X POST https://everysecond.ai/api/v1/chats/17/messages \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "message": "بتوصلوا المعادي؟ والتوصيل بكام؟" }'
Voice note
curl -X POST https://everysecond.ai/api/v1/chats/17/messages \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -F "audio=@voice-note.ogg" \
  -F "language=ar"
Reply of an agent that takes orders
{
  "chat_id": 17,
  "status": "replied",
  "reply": "عندنا بوكس الكشري العائلي بـ 145 جنيه بدل 160 لحد آخر الشهر. أضيفهولك؟",
  "message_id": 51211,
  "reply_id": 51212,
  "sent_at": "2026-10-07T11:08:15+00:00",
  "products": [
    {
      "menu_item_id": 2469,
      "external_id": "KSH-FAM",
      "branch_id": 177,
      "name": "بوكس كشري عائلي",
      "description": "كشري لأربعة أفراد مع بصل مقرمش زيادة وصلصة ودقة على الجنب.",
      "category": "Koshary",
      "price": 145.0,
      "original_price": 160.0,
      "offer_name": "October deal",
      "currency": "EGP",
      "price_text": "145.00 جنيه",
      "image_url": "https://everysecond.ai/storage/menu-items/cards/812/2469.jpg",
      "image_full_url": "https://everysecond.ai/storage/menu-items/812/family-box.jpg",
      "product_url": "https://kosharyelnil.com/menu/family-box",
      "buy_url": null,
      "orderable": true,
      "in_stock": true,
      "options": null
    }
  ],
  "cart": {
    "id": 4471,
    "project_id": 812,
    "branch_id": 177,
    "lead_id": 90547,
    "sender_id": "api_812_k3x9q2m7v1b4n8c5",
    "customer_name": "Mona Adel",
    "status": "active",
    "items": [
      { "line_id": "item:2469", "menu_item_id": 2469, "external_id": "KSH-FAM", "offer_id": null, "name": "Family koshary box", "unit_price": 145.0, "qty": 2, "line_total": 290.0 },
      { "line_id": "item:2481", "menu_item_id": 2481, "external_id": "RZ-LBN", "offer_id": null, "name": "Rice pudding", "unit_price": 35.0, "qty": 1, "line_total": 35.0 }
    ],
    "items_total": 325.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "agent",
    "closed_at": null,
    "closed_reason": null,
    "last_activity_at": "2026-10-07T11:08:12+00:00",
    "created_at": "2026-10-07T11:04:31+00:00"
  },
  "order": null,
  "lead_id": 90547
}
Reply to a voice note
{
  "chat_id": 17,
  "status": "replied",
  "reply": "أيوه بنوصل المعادي، التوصيل 25 جنيه وبيوصل خلال ساعة.",
  "message_id": 51213,
  "reply_id": 51214,
  "sent_at": "2026-10-07T11:09:02+00:00",
  "transcript": "بتوصلوا المعادي؟ والتوصيل بكام؟",
  "audio": {
    "url": "https://everysecond.ai/storage/inbound-media/812/9b1f2c7d4e.ogg",
    "mime": "audio/ogg",
    "size": 18422
  },
  "products": [],
  "lead_id": 90547
}
Response
{
  "chat_id": 17,
  "status": "replied",
  "reply": "أيوه بنوصل المعادي، التوصيل 25 جنيه وبيوصل خلال ساعة. تحب تطلب إيه؟",
  "message_id": 51207,
  "reply_id": 51208,
  "sent_at": "2026-10-07T11:06:44+00:00",
  "products": [],
  "lead_id": 90547
}
Errors (6)
StatusCodeWhen
403plan_endedThe subscription behind this agent has ended. Renew it to resume replies.
403quota_reachedThe plan’s message quota for this cycle is used up; error carries the limit.
404Not foundNo such chat on this agent.
422Validationmessage missing or over 4000 characters, both message and audio sent, or audio over 25 MB.
422invalid_audioaudio is not a voice recording (a photo or a PDF, say).
502errorstatus: "error" — the agent could not answer; a ticket was opened for the team. The body still carries chat_id, message_id and an error message.

Read the transcript

GET/api/v1/chats/{id}/messages

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).

ParameterInTypeDescription
idrequiredpathintegerThe chat id.
limitqueryinteger1–500 messages, default 100.
beforequeryintegerA message id: only messages older than it.
afterqueryintegerA 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"
Response
{
  "chat_id": 17,
  "messages": [
    {
      "id": 51207,
      "direction": "inbound",
      "author": "customer",
      "text": "بتوصلوا المعادي؟ والتوصيل بكام؟",
      "media": null,
      "sent_at": "2026-10-07T11:06:40+00:00"
    },
    {
      "id": 51208,
      "direction": "outbound",
      "author": "agent",
      "text": "أيوه بنوصل المعادي، التوصيل 25 جنيه وبيوصل خلال ساعة. تحب تطلب إيه؟",
      "media": null,
      "sent_at": "2026-10-07T11:06:44+00:00"
    },
    {
      "id": 51213,
      "direction": "inbound",
      "author": "customer",
      "text": "عايزة اتنين بوكس عائلي",
      "media": [
        {
          "type": "audio",
          "mime": "audio/ogg",
          "size": 18422,
          "url": "https://everysecond.ai/storage/inbound-media/812/9b1f2c7d4e.ogg"
        }
      ],
      "sent_at": "2026-10-07T11:09:00+00:00"
    },
    {
      "id": 51220,
      "direction": "outbound",
      "author": "human",
      "text": "أهلاً يا مدام منى، معاكي كريم من الفرع. الأوردر هيوصلك خلال 45 دقيقة.",
      "media": null,
      "sent_at": "2026-10-07T11:12:31+00:00"
    }
  ],
  "has_more": false,
  "total": 4
}
Errors (2)
StatusCodeWhen
404Not foundNo such chat on this agent.
422Validationlimit above 500, or a cursor that is not an integer.

Get the basket

GET/api/v1/chats/{id}/cart

The customer’s open basket in this chat, or null.

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).

ParameterInTypeDescription
idrequiredpathintegerThe chat id.
BASH
curl https://everysecond.ai/api/v1/chats/17/cart \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{ "cart": {
    "id": 4471,
    "project_id": 812,
    "branch_id": 177,
    "lead_id": 90547,
    "sender_id": "api_812_k3x9q2m7v1b4n8c5",
    "customer_name": "Mona Adel",
    "status": "active",
    "items": [
      { "line_id": "item:2469", "menu_item_id": 2469, "external_id": "KSH-FAM", "offer_id": null, "name": "Family koshary box", "unit_price": 145.0, "qty": 2, "line_total": 290.0 },
      { "line_id": "item:2481", "menu_item_id": 2481, "external_id": "RZ-LBN", "offer_id": null, "name": "Rice pudding", "unit_price": 35.0, "qty": 1, "line_total": 35.0 }
    ],
    "items_total": 325.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "agent",
    "closed_at": null,
    "closed_reason": null,
    "last_activity_at": "2026-10-07T11:08:12+00:00",
    "created_at": "2026-10-07T11:04:31+00:00"
  } }
Errors (1)
StatusCodeWhen
404Not foundNo such chat on this agent.

Replace the basket

PUT/api/v1/chats/{id}/cart

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.

ParameterInTypeDescription
idrequiredpathintegerThe chat id.
itemsrequiredbodyarray<object>The basket’s lines, up to 100. Must be present; may be empty.
items[].external_idbodystringYour product id (up to 191 characters).
items[].menu_item_idbodyintegerEverySecond menu item id.
items[].bundle_idbodyintegerA bundle offer id.
items[].qtyrequiredbodyinteger1–999.
branch_idbodyintegerThe branch the basket belongs to; needed only when the agent sells from more than one branch and the chat has no open basket.
BASH
curl -X PUT https://everysecond.ai/api/v1/chats/17/cart \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "items": [ { "external_id": "KSH-FAM", "qty": 5 }, { "menu_item_id": 2481, "qty": 1 }, { "external_id": "DRK-PEPSI", "qty": 1 } ] }'
Response
{
  "cart": {
    "id": 4471,
    "project_id": 812,
    "branch_id": 177,
    "lead_id": 90547,
    "sender_id": "api_812_k3x9q2m7v1b4n8c5",
    "customer_name": "Mona Adel",
    "status": "active",
    "items": [
      { "line_id": "item:2469", "menu_item_id": 2469, "external_id": "KSH-FAM", "offer_id": null, "name": "Family koshary box", "unit_price": 145.0, "qty": 2, "line_total": 290.0 },
      { "line_id": "item:2481", "menu_item_id": 2481, "external_id": "RZ-LBN", "offer_id": null, "name": "Rice pudding", "unit_price": 35.0, "qty": 1, "line_total": 35.0 }
    ],
    "items_total": 325.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "api",
    "closed_at": null,
    "closed_reason": null,
    "last_activity_at": "2026-10-07T11:08:12+00:00",
    "created_at": "2026-10-07T11:04:31+00:00"
  },
  "skipped": [
    { "external_id": "DRK-PEPSI", "qty": 1, "reason": "not_found" }
  ],
  "adjusted": [
    { "external_id": "KSH-FAM", "requested_qty": 5, "qty": 2, "reason": "max_qty_per_order" }
  ]
}
Errors (5)
StatusCodeWhen
404Not foundNo such chat on this agent.
409No ordersThis agent does not take orders (Orders module off).
422BranchNo such branch on this agent., or This agent sells from more than one branch — say which one in branch_id.
422Nothing fitsNone of these items can be put in the basket. — every line was refused; the body lists them in skipped and the basket is unchanged.
422Validationitems missing, more than 100 lines, or a qty outside 1–999.

Close the basket

DELETE/api/v1/chats/{id}/cart

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.

ParameterInTypeDescription
idrequiredpathintegerThe chat id.
reasonbodystringWhy it closed, e.g. paid_on_website. Up to 100 characters; also accepted as ?reason=.
BASH
curl -X DELETE https://everysecond.ai/api/v1/chats/17/cart \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "paid_on_website" }'
200 — there was no open basket (safe to repeat)
{
  "closed": false,
  "cart": null
}
Response
{
  "closed": true,
  "cart": {
    "id": 4471,
    "project_id": 812,
    "branch_id": 177,
    "lead_id": 90547,
    "sender_id": "api_812_k3x9q2m7v1b4n8c5",
    "customer_name": "Mona Adel",
    "status": "closed",
    "items": [
      { "line_id": "item:2469", "menu_item_id": 2469, "external_id": "KSH-FAM", "offer_id": null, "name": "Family koshary box", "unit_price": 145.0, "qty": 2, "line_total": 290.0 }
    ],
    "items_total": 290.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "api",
    "closed_at": "2026-10-07T11:31:02+00:00",
    "closed_reason": "paid_on_website",
    "last_activity_at": "2026-10-07T11:31:02+00:00",
    "created_at": "2026-10-07T11:04:31+00:00"
  }
}
Errors (2)
StatusCodeWhen
404Not foundNo such chat on this agent.
422Validationreason longer than 100 characters.

Put the agent inside your app

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
Conversations

Website chat widget

API key in the URL · widget enabled

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.

Launcher script

GET/embed/{api_key}/launcher

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).

ParameterInTypeDescription
api_keyrequiredpathstringThe 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)
StatusCodeWhen
404Not foundUnknown key or widget disabled — body /* Widget not found or disabled */.

Widget page

GET/embed/{api_key}

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.

ParameterInTypeDescription
api_keyrequiredpathstringThe agent’s API key (es_…).
colorquerystringA #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)
StatusCodeWhen
403ForbiddenUnknown key or widget disabled (Widget not found or disabled., HTML error page).

Send a widget message

POST/embed/{api_key}/chat

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).

ParameterInTypeDescription
api_keyrequiredpathstringThe agent’s API key (es_…). The widget must be enabled.
messagebodystringThe visitor’s text, up to 2000 characters. Required unless audio is sent; cannot be combined with audio.
audiobodyfileA voice recording (multipart), up to 10 MB. Required unless message is sent.
messagesbodyarray<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_idbodystringThe 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.
Text
curl -X POST https://everysecond.ai/embed/es_7Hk2Lm9Qx4Rt8Vw1Zc6Bn3Hs5Jd0GaYe/chat \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "message": "عندكم توصيل للمعادي؟", "messages": [ { "role": "user", "content": "السلام عليكم" }, { "role": "assistant", "content": "وعليكم السلام! أقدر أساعدك إزاي؟" } ] }'
Voice note
curl -X POST https://everysecond.ai/embed/es_7Hk2Lm9Qx4Rt8Vw1Zc6Bn3Hs5Jd0GaYe/chat \
  -H "Accept: application/json" \
  -F "audio=@note.webm" \
  -F 'messages=[]'
200 — voice note
{
  "reply": "أيوه بنوصل المعادي، التوصيل 25 جنيه.",
  "transcript": "عندكم توصيل للمعادي؟"
}
200 — agent that takes orders
{
  "reply": "عندنا بوكس الكشري العائلي بـ 145 جنيه. أضيفهولك؟",
  "products": [
    {
      "menu_item_id": 2469,
      "external_id": "KSH-FAM",
      "branch_id": 177,
      "name": "بوكس كشري عائلي",
      "description": null,
      "category": "Koshary",
      "price": 145.0,
      "original_price": null,
      "offer_name": null,
      "currency": "EGP",
      "price_text": "145.00 جنيه",
      "image_url": "https://everysecond.ai/storage/menu-items/cards/812/2469.jpg",
      "image_full_url": "https://everysecond.ai/storage/menu-items/812/family-box.jpg",
      "product_url": null,
      "buy_url": null,
      "orderable": true,
      "in_stock": true,
      "options": null
    }
  ],
  "status": "replied",
  "visitor_id": "v_3f9c1a7e5b2d4c6a8e0f"
}
Response
{
  "reply": "أهلاً بيك في كشري النيل! تحب تعرف المنيو ولا أسعار التوصيل؟"
}
Errors (5)
StatusCodeWhen
403ForbiddenWidget not found or disabled. — unknown key or widget switched off.
422invalid_audioaudio is not a voice recording.
422voice_unreadableNothing could be made out of the voice note; ask the visitor to retry or type.
422Validationmessage missing or over 2000 characters, both message and audio sent, audio over 10 MB, or more than 20 history items.
429Rate limitedRate 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).

Read the agent

GET/api/v1/agent

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.

BASH
curl https://everysecond.ai/api/v1/agent \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "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"
  },
  "industry": {
    "id": 9,
    "name": "Restaurant",
    "slug": "restaurant-full",
    "category": {
      "id": 1,
      "name": "Restaurant & Café",
      "slug": "restaurant"
    }
  },
  "connections": {
    "facebook": {
      "connected": true,
      "name": "Koshary El Nil",
      "external_id": "104857392011873",
      "connect_url": "https://everysecond.ai/facebook/login/812"
    },
    "instagram": {
      "connected": true,
      "name": "koshary.elnil",
      "external_id": "17841456012345678",
      "connect_url": "https://everysecond.ai/facebook/login/812"
    },
    "whatsapp": {
      "connected": false,
      "name": null,
      "external_id": null,
      "connect_url": "https://everysecond.ai/whatsapp/connect/812"
    },
    "widget": {
      "connected": true,
      "name": null,
      "external_id": null,
      "connect_url": "https://everysecond.ai/projects/812"
    }
  }
}
Errors (1)
StatusCodeWhen
401UnauthorizedMissing or unknown API key.

Edit the agent

PATCH/api/v1/agent

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.

ParameterInTypeDescription
namebodystringWhat the agent is called, 2–120 characters.
identitybodystringUp to 255 characters, dropped into the model’s prompt as “You are …”. Include the agent’s name.
tonebodystringThe voice every reply is written in, up to 255 characters.
instructionsbodyarray<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_verbositybodyenum: short | balanced | detailedReply length. concise (the dashboard’s name for short) is accepted too and reads back as short.
disable_emojisbodybooleanStrip emoji from every reply.
comment_replybodystringCanned public reply to social comments, up to 1000 characters.
human_takeover_hoursbodyinteger 0–168How 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.
reply_dialectbodyenum: auto | mirror_neutral | gulf | levantine | egyptian | msaArabic 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_noticebodyenum: agent | silentWhen 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_requestbodyenum: once | agent | neverWhen 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_countrybodystring | nullWhere the business is, e.g. United Arab Emirates. Reads local phone numbers (0501234567 → +971501234567) and sets the default dialect. null = detected.
industry_idbodyintegerAn existing industry id (from the industries offered in setup), or null.
statusbodyenum: draft | testing | live | archivedlive requires a paid plan.
BASH
curl -X PATCH https://everysecond.ai/api/v1/agent \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "tone": "Professional and precise", "reply_verbosity": "short" }'
Response
{
  "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)
StatusCodeWhen
403PlanThis plan cannot take an agent live. Upgrade the subscription first.
422Nothing to updateNone of the editable keys was sent.
422ValidationA value breaks its rule (e.g. name shorter than 2 characters, an unknown industry_id, a status outside the enum).

Append an instruction

POST/api/v1/agent/instructions

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".

ParameterInTypeDescription
instructionrequiredbodystring3–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)
StatusCodeWhen
422Validationinstruction missing, shorter than 3 or longer than 1000 characters.

List knowledge

GET/api/v1/agent/knowledge

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.

BASH
curl https://everysecond.ai/api/v1/agent/knowledge \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "documents": [
    {
      "id": "api_6703f1a2b4c5d1.23456789",
      "type": "Manual Text",
      "title": "Delivery policy",
      "name": "Delivery policy",
      "excerpt": "We deliver inside Maadi, Zahraa El Maadi and Degla for 25 EGP within an hour. New Cairo is 45 EGP…"
    },
    {
      "id": "pdf_6703f2c9d1e8a4.87654321",
      "type": "menu-october.pdf",
      "title": "Family koshary box 145 EGP · Large koshary 55 EGP",
      "name": "Family koshary box 145 EGP · Large koshary 55 EGP",
      "excerpt": "Family koshary box 145 EGP · Large koshary 55 EGP · Rice pudding 35 EGP…"
    }
  ],
  "total": 2
}
Errors (1)
StatusCodeWhen
503UnavailableThe knowledge service is unavailable right now. Try again shortly.

Add knowledge

POST/api/v1/agent/knowledge

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".

ParameterInTypeDescription
typebodyenum: text | file | urlWhat you are sending. Default: file if a file is attached, else text.
textbodystringtype=text: the content, 5–100000 characters. Required for text.
titlebodystringtype=text: a title for the block, up to 120 characters — shown in the knowledge listing.
filebodyfiletype=file: pdf, docx, txt, xlsx or xls, up to the plan’s upload limit. Required for file.
urlbodystringtype=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." }'
File
curl -X POST https://everysecond.ai/api/v1/agent/knowledge \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -F "type=file" \
  -F "file=@menu-october.pdf"
Web page
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": "url", "url": "https://kosharyelnil.com/delivery" }'
201 — file
{
  "document": {
    "id": "pdf_6703f2c9d1e8a4.87654321",
    "type": "file",
    "name": "menu-october.pdf"
  },
  "task_id": "b8e4c1d2-5f7a-4e3b-9c1d-2a6f8e0b4d71"
}
201 — web page
{
  "document": {
    "id": "url_6703f3e7a9b2c5.13579246",
    "type": "url",
    "source": "https://kosharyelnil.com/delivery"
  },
  "task_id": "c3a9e5f1-7b2d-4c8e-a1f3-9d5b7e2c6a48"
}
201 — text
{
  "document": {
    "id": "api_6703f1a2b4c5d1.23456789",
    "type": "text",
    "title": "Delivery policy",
    "chars": 812
  }
}
Errors (3)
StatusCodeWhen
422Unknown typetype must be one of: text, file, url.
422ValidationMissing text / file / url, text shorter than 5 characters, a file of another format or over the limit, an invalid URL.
502IndexThe 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.

Delete knowledge

DELETE/api/v1/agent/knowledge/{id}

Remove one document from the agent’s knowledge.

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.

ParameterInTypeDescription
idrequiredpathstringThe document id from the listing (api_…, pdf_…, url_…).
BASH
curl -X DELETE https://everysecond.ai/api/v1/agent/knowledge/pdf_6703f2c9d1e8a4.87654321 \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "deleted": true,
  "id": "pdf_6703f2c9d1e8a4.87654321"
}
Errors (1)
StatusCodeWhen
502IndexThat document could not be deleted.

List channels

GET/api/v1/agent/connections

Which channels the agent answers on, and the last 20 connection requests.

Request status is pending, completed (the channel connected — closed automatically) or cancelled.

BASH
curl https://everysecond.ai/api/v1/agent/connections \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "connections": {
    "facebook": {
      "connected": true,
      "name": "Koshary El Nil",
      "external_id": "104857392011873",
      "connect_url": "https://everysecond.ai/facebook/login/812"
    },
    "instagram": {
      "connected": true,
      "name": "koshary.elnil",
      "external_id": "17841456012345678",
      "connect_url": "https://everysecond.ai/facebook/login/812"
    },
    "whatsapp": {
      "connected": false,
      "name": null,
      "external_id": null,
      "connect_url": "https://everysecond.ai/whatsapp/connect/812"
    },
    "widget": {
      "connected": true,
      "name": null,
      "external_id": null,
      "connect_url": "https://everysecond.ai/projects/812"
    }
  },
  "requests": [
    {
      "id": 12,
      "channel": "whatsapp",
      "status": "pending",
      "note": "Please connect the branch number 01112345678.",
      "connect_url": "https://everysecond.ai/whatsapp/connect/812",
      "requires_login": true,
      "requested_at": "2026-10-07T11:45:30+00:00",
      "completed_at": null
    },
    {
      "id": 9,
      "channel": "meta",
      "status": "completed",
      "note": null,
      "connect_url": "https://everysecond.ai/facebook/login/812",
      "requires_login": true,
      "requested_at": "2026-09-30T08:20:11+00:00",
      "completed_at": "2026-09-30T09:02:47+00:00"
    }
  ]
}

Request a channel connection

POST/api/v1/agent/connections/{id}

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.

ParameterInTypeDescription
idrequiredpathenum: meta | whatsappThe channel to connect.
notebodystringA 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." }'
200 — a request for this channel was already open
{
  "connection": {
    "id": 12,
    "channel": "whatsapp",
    "status": "pending",
    "note": "Please connect the branch number 01112345678.",
    "connect_url": "https://everysecond.ai/whatsapp/connect/812",
    "requires_login": true,
    "requested_at": "2026-10-07T11:45:30+00:00",
    "completed_at": null
  },
  "already_open": true
}
Response
{
  "connection": {
    "id": 12,
    "channel": "whatsapp",
    "status": "pending",
    "note": "Please connect the branch number 01112345678.",
    "connect_url": "https://everysecond.ai/whatsapp/connect/812",
    "requires_login": true,
    "requested_at": "2026-10-07T11:45:30+00:00",
    "completed_at": null
  }
}
Errors (3)
StatusCodeWhen
409Already connectedThat channel is already connected. — the body adds connections.
422Unknown channelUnknown channel. Use one of: meta, whatsapp.
422Validationnote longer than 500 characters.

Cancel a connection request

DELETE/api/v1/agent/connections/{id}

Cancel a pending connection request.

A pending request becomes cancelled. A request that is already completed or cancelled is returned unchanged with 200.

ParameterInTypeDescription
idrequiredpathintegerThe connection request id (not the channel name).
BASH
curl -X DELETE https://everysecond.ai/api/v1/agent/connections/12 \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "connection": {
    "id": 12,
    "channel": "whatsapp",
    "status": "cancelled",
    "note": "Please connect the branch number 01112345678.",
    "connect_url": "https://everysecond.ai/whatsapp/connect/812",
    "requires_login": true,
    "requested_at": "2026-10-07T11:45:30+00:00",
    "completed_at": null
  }
}
Errors (1)
StatusCodeWhen
404Not foundNo such connection request.

Onboard an agent end to end, headless

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
Agent

Leads feed

API key

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.

List leads

GET/api/v1/leads

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.

ParameterInTypeDescription
sincequeryISO-8601 stringOnly leads created at or after this time.
untilqueryISO-8601 stringOnly leads created at or before this time.
channelqueryenum: facebook | instagram | whatsapp | websiteThe stored two-letter codes fb, ig, wp, web are accepted too.
min_scorequeryinteger0–100: only leads at or above this score.
flagquerystringExact flag name, up to 120 characters.
has_phonequerybooleanOnly leads with a real phone number.
searchquerystringPart of the name or phone, up to 120 characters.
per_pagequeryinteger1–200, default 50.
pagequeryintegerPage number, from 1.
orderqueryenum: asc | descDefault asc (oldest first).
BASH
curl "https://everysecond.ai/api/v1/leads?since=2026-10-01T00:00:00%2B02:00&min_score=20&has_phone=1&per_page=100" \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "leads": [
    {
      "id": 90412,
      "created_at": "2026-10-07T11:02:11+00:00",
      "updated_at": "2026-10-07T11:06:48+00:00",
      "contact": {
        "name": "Mona Adel",
        "first_name": "Mona",
        "last_name": "Adel",
        "phone": "01001234567",
        "phone_e164": "+201001234567",
        "email": null,
        "language": "ar",
        "external_id": "201001234567"
      },
      "attribution": {
        "source": "everysecond",
        "channel": "whatsapp",
        "channel_code": "wp",
        "agent_id": 812,
        "agent_name": "Nour"
      },
      "crm": {
        "status": "new",
        "assigned_to": null,
        "source": "chat",
        "next_follow_up": null
      },
      "qualification": {
        "score": 64,
        "score_reason": "Asked for prices and gave a delivery address",
        "summary": "Wants two family koshary boxes delivered to Maadi tonight.",
        "inquiry": "Delivery to Maadi",
        "flags": [
          "Hot lead"
        ]
      },
      "links": {
        "conversation": "https://everysecond.ai/api/v1/leads/90412/conversation",
        "dashboard": "https://everysecond.ai/leads/90412"
      }
    }
  ],
  "pagination": {
    "page": 1,
    "per_page": 100,
    "total": 248,
    "total_pages": 3,
    "has_more": true
  }
}
Errors (1)
StatusCodeWhen
422ValidationAn unparseable date, an unknown channel, min_score outside 0–100 or per_page above 200.

List flags

GET/api/v1/leads/flags

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.

BASH
curl https://everysecond.ai/api/v1/leads/flags \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "flags": [
    {
      "id": 31,
      "name": "Complaint",
      "description": "Unhappy with an order or the delivery.",
      "assigned_by": "ai"
    },
    {
      "id": 27,
      "name": "Hot lead",
      "description": "Ready to order now.",
      "assigned_by": "ai"
    },
    {
      "id": 29,
      "name": "Wholesale",
      "description": "Asks for catering or bulk prices.",
      "assigned_by": "keywords"
    }
  ]
}

Get a lead

GET/api/v1/leads/{id}

One lead, in the same shape the lead.* webhooks push.

ParameterInTypeDescription
idrequiredpathintegerThe lead id.
BASH
curl https://everysecond.ai/api/v1/leads/90412 \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "lead": {
    "id": 90412,
    "created_at": "2026-10-07T11:02:11+00:00",
    "updated_at": "2026-10-07T11:06:48+00:00",
    "contact": {
      "name": "Mona Adel",
      "first_name": "Mona",
      "last_name": "Adel",
      "phone": "01001234567",
      "phone_e164": "+201001234567",
      "email": null,
      "language": "ar",
      "external_id": "201001234567"
    },
    "attribution": {
      "source": "everysecond",
      "channel": "whatsapp",
      "channel_code": "wp",
      "agent_id": 812,
      "agent_name": "Nour"
    },
    "crm": {
      "status": "new",
      "assigned_to": null,
      "source": "chat",
      "next_follow_up": null
    },
    "qualification": {
      "score": 64,
      "score_reason": "Asked for prices and gave a delivery address",
      "summary": "Wants two family koshary boxes delivered to Maadi tonight.",
      "inquiry": "Delivery to Maadi",
      "flags": [
        "Hot lead"
      ]
    },
    "links": {
      "conversation": "https://everysecond.ai/api/v1/leads/90412/conversation",
      "dashboard": "https://everysecond.ai/leads/90412"
    }
  }
}
Errors (1)
StatusCodeWhen
404Not foundNo such lead. — including a lead of another agent.

Read a lead’s conversation

GET/api/v1/leads/{id}/conversation

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.

ParameterInTypeDescription
idrequiredpathintegerThe lead id.
limitqueryinteger1–500 messages per page, default 200.
beforequeryintegerA message id: return the page of messages older than it. Pass the previous page’s meta.next_before.
Newest page
curl "https://everysecond.ai/api/v1/leads/90412/conversation?limit=4" \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
The page before it
curl "https://everysecond.ai/api/v1/leads/90412/conversation?limit=4&before=50114" \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "lead": {
    "id": 90412,
    "created_at": "2026-10-07T11:02:11+00:00",
    "updated_at": "2026-10-07T11:06:48+00:00",
    "contact": {
      "name": "Mona Adel",
      "first_name": "Mona",
      "last_name": "Adel",
      "phone": "01001234567",
      "phone_e164": "+201001234567",
      "email": null,
      "language": "ar",
      "external_id": "201001234567"
    },
    "attribution": {
      "source": "everysecond",
      "channel": "whatsapp",
      "channel_code": "wp",
      "agent_id": 812,
      "agent_name": "Nour"
    },
    "crm": {
      "status": "new",
      "assigned_to": null,
      "source": "chat",
      "next_follow_up": null
    },
    "qualification": {
      "score": 64,
      "score_reason": "Asked for prices and gave a delivery address",
      "summary": "Wants two family koshary boxes delivered to Maadi tonight.",
      "inquiry": "Delivery to Maadi",
      "flags": [
        "Hot lead"
      ]
    },
    "links": {
      "conversation": "https://everysecond.ai/api/v1/leads/90412/conversation",
      "dashboard": "https://everysecond.ai/leads/90412"
    }
  },
  "messages": [
    {
      "id": 50114,
      "direction": "inbound",
      "author": "contact",
      "text": "السلام عليكم، بتوصلوا المعادي؟",
      "sent_at": "2026-10-07T11:02:09+00:00"
    },
    {
      "id": 50115,
      "direction": "outbound",
      "author": "agent",
      "text": "وعليكم السلام! أيوه بنوصل المعادي، التوصيل 25 جنيه.",
      "sent_at": "2026-10-07T11:02:14+00:00"
    },
    {
      "id": 50131,
      "direction": "inbound",
      "author": "contact",
      "text": "عايزة اتنين بوكس عائلي على 12 شارع 9",
      "sent_at": "2026-10-07T11:06:40+00:00"
    },
    {
      "id": 50138,
      "direction": "outbound",
      "author": "human",
      "text": "أهلاً يا فندم، معاكي أحمد من الفرع — الطلب هيوصلك خلال ساعة.",
      "sent_at": "2026-10-07T11:09:02+00:00"
    }
  ],
  "total": 4,
  "meta": {
    "limit": 4,
    "has_more": true,
    "next_before": 50114
  }
}
Errors (2)
StatusCodeWhen
404Not foundNo such lead.
422Validationlimit outside 1–500, or before not a positive integer.

The lead object

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.

JSON
{
  "id": 90412,
  "created_at": "2026-10-07T11:02:11+00:00",
  "updated_at": "2026-10-07T11:06:48+00:00",
  "contact": {
    "name": "Mona Adel",
    "first_name": "Mona",
    "last_name": "Adel",
    "phone": "01001234567",
    "phone_e164": "+201001234567",
    "email": null,
    "language": "ar",
    "external_id": "201001234567"
  },
  "attribution": {
    "source": "everysecond",
    "channel": "whatsapp",
    "channel_code": "wp",
    "agent_id": 812,
    "agent_name": "Nour"
  },
  "crm": {
    "status": "new",
    "assigned_to": null,
    "source": "chat",
    "next_follow_up": null
  },
  "qualification": {
    "score": 64,
    "score_reason": "Asked for prices and gave a delivery address",
    "summary": "Wants two family koshary boxes delivered to Maadi tonight.",
    "inquiry": "Delivery to Maadi",
    "flags": [
      "Hot lead"
    ]
  },
  "links": {
    "conversation": "https://everysecond.ai/api/v1/leads/90412/conversation",
    "dashboard": "https://everysecond.ai/leads/90412"
  }
}

Sync leads without gaps

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.

BASH
curl "https://everysecond.ai/api/v1/leads?since=2026-10-06T23:58:12%2B00:00&order=asc&per_page=200&page=1" \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Commerce

Reservations

API key · Reservations add-on on

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.

Read the setup

GET/api/v1/reservations/setup

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.

BASH
curl https://everysecond.ai/api/v1/reservations/setup -H "Authorization: Bearer $EVERYSECOND_KEY"
A tour operator's branch (tourism)
{
  "id": 7, "name": "Downtown office", "address": "Talaat Harb St", "available_time": {},
  "photos": [], "specialties": [], "rooms": [],
  "programs": [{
    "id": 31, "kind": "program", "name": "Siwa Oasis", "parent_id": null, "active": true,
    "meta": { "price": 4500, "duration": "3 days / 2 nights", "highlights": ["Salt lakes", "Shali fortress"], "meeting_point": "Tahrir Square" },
    "photos": [{ "id": 88, "url": "https://everysecond.ai/storage/reservation-photos/2/5c1e….jpg", "width": 1600, "height": 1067, "mime": "image/jpeg", "sort_order": 0 }],
    "departures": [{
      "id": 502, "program_id": 31, "program": { "id": 31, "name": "Siwa Oasis" }, "branch_id": 7,
      "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00",
      "capacity": 20, "booked": 20, "seats_left": 0, "waitlist": 2, "waitlist_travelers": 5,
      "price": 3900.0, "price_override": 3900.0, "active": true, "is_full": true, "notes": null,
      "series": "8f0c2d1e-5b7a-4c1e-9a43-0d6f2b9c7e11"
    }]
  }]
}
Response
{
  "data": {
    "project_id": 2, "type": "medical", "types": ["general", "medical", "hotel"],
    "check_time": "12:00", "max_nights": 60, "timezone": "Africa/Cairo",
    "branches": [{
      "id": 3, "name": "Nasr City", "address": "Abbas El Akkad St", "available_time": {},
      "specialties": [{
        "id": 7, "kind": "specialty", "name": "Cardiology", "parent_id": null, "active": true,
        "clinics": [{
          "id": 9, "kind": "clinic", "name": "Clinic 2", "parent_id": 7, "active": true,
          "doctors": [{
            "id": 12, "kind": "doctor", "name": "Dr. Mona Adel", "parent_id": 9, "active": true,
            "meta": { "title": "Consultant", "fee": 500 }, "slot_minutes": 30,
            "schedule": { "0": ["09:00-13:00", "17:00-21:00"], "2": ["09:00-13:00"] }
          }]
        }]
      }],
      "rooms": [],
      "photos": [],
      "programs": []
    }],
    "photo_limits": { "hotel": 12, "room": 8, "program": 8, "max_kb": 8192 },
    "counts": { "specialty": 1, "clinic": 1, "doctor": 1, "room": 0, "program": 0 }
  }
}
Errors (3)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.

Find free times or rooms

GET/api/v1/reservations/availability

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.

ParameterInTypeDescription
daterequiredqueryYYYY-MM-DDgeneral / medical: the local day.
branch_idqueryintegergeneral: one branch; medical / hotel: only its doctors / rooms.
doctor_idqueryintegermedical: one doctor.
specialty_idqueryintegermedical: only the doctors of this specialty.
durationqueryinteger 5–480Slot length in minutes (general default: the branch slot length; medical default: the doctor's).
check_inqueryYYYY-MM-DDhotel (required): the arrival day.
nightsqueryinteger 1–60hotel: default 1.
room_typequerystringhotel: only rooms of this type (case-insensitive).
guestsqueryintegerhotel: only rooms that hold this many people.
from / toqueryYYYY-MM-DDtourism: the local days to list departures for (default today → +90; at most 400 days).
program_idqueryintegertourism: one trip program.
party_sizequeryinteger 1–200tourism: travelers — sets fits (default 1).
A doctor on a day
curl "https://everysecond.ai/api/v1/reservations/availability?date=2026-10-12&doctor_id=12" -H "Authorization: Bearer $EVERYSECOND_KEY"
Hotel rooms for two nights
curl "https://everysecond.ai/api/v1/reservations/availability?check_in=2026-10-12&nights=2&room_type=Deluxe&guests=2" -H "Authorization: Bearer $EVERYSECOND_KEY"
Trip departures for a group of 3
curl "https://everysecond.ai/api/v1/reservations/availability?program_id=31&from=2026-10-12&to=2026-11-30&party_size=3" -H "Authorization: Bearer $EVERYSECOND_KEY"
tourism response
{
  "data": {
    "type": "tourism", "from": "2026-10-12", "to": "2026-11-30", "party_size": 3,
    "departures": [{
        "id": 502, "program_id": 31, "program": { "id": 31, "name": "Siwa Oasis" }, "branch_id": 7,
        "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00",
        "capacity": 20, "booked": 20, "seats_left": 0, "waitlist": 2, "waitlist_travelers": 5,
        "price": 3900.0, "price_override": 3900.0, "active": true, "is_full": true, "notes": null,
        "series": "8f0c2d1e-5b7a-4c1e-9a43-0d6f2b9c7e11",
        "fits": false, "waitlist_possible": true
      }]
  }
}
Response
{
  "data": {
    "type": "medical", "date": "2026-10-12",
    "doctors": [{
      "doctor": { "id": 12, "kind": "doctor", "name": "Dr. Mona Adel", "slot_minutes": 30, "path": ["Cardiology", "Clinic 2", "Dr. Mona Adel"] },
      "closed": false, "slot_minutes": 30,
      "slots": [
        { "start": "2026-10-12T09:00:00+03:00", "end": "2026-10-12T09:30:00+03:00", "available": true },
        { "start": "2026-10-12T09:30:00+03:00", "end": "2026-10-12T10:00:00+03:00", "available": false }
      ]
    }]
  }
}
Errors (6)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
404not_foundUnknown branch_id / doctor_id / program_id.
422validation_failedA required parameter is missing or malformed, or more than 400 days asked (tourism).
422pasthotel: the check-in day has passed.

List reservations

GET/api/v1/reservations

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.

ParameterInTypeDescription
fromqueryYYYY-MM-DDFirst local day (days mode).
toqueryYYYY-MM-DDLast local day (days mode).
updated_sincequeryISO-8601 stringSync mode: changes after this moment. URL-encode the +.
statusqueryenum: pending | confirmed | rescheduled | cancelled | waitlistedOnly this status (waitlisted = every booking waiting for seats).
branch_idqueryintegerOne branch.
resource_idqueryintegerOne doctor, room or trip program.
departure_idqueryintegertourism: one departure (its bookings and waiting list).
sourcequeryenum: ai | dashboard | apiWhere it was made.
phonequerystringCustomer phone contains these digits.
per_pagequeryinteger 1–200Default 50.
pagequeryintegerPage number.
This week
curl "https://everysecond.ai/api/v1/reservations?from=2026-10-12&to=2026-10-18" -H "Authorization: Bearer $EVERYSECOND_KEY"
Everything changed since the last sync
curl "https://everysecond.ai/api/v1/reservations?updated_since=2026-10-08T09:00:00%2B00:00&per_page=200" -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [ {
    "id": 482,
    "external_ref": "HIS-88421",
    "branch_id": 3,
    "branch_name": "Nasr City",
    "resource_id": 12,
    "resource": {
      "id": 12, "kind": "doctor", "name": "Dr. Mona Adel",
      "path": ["Cardiology", "Clinic 2", "Dr. Mona Adel"],
      "meta": { "title": "Consultant", "fee": 500 }
    },
    "check_in": null, "check_out": null, "nights": null,
    "lead_id": 9182, "bot_id": null, "sender_id": null,
    "customer_name": "Ahmed Samir",
    "customer_phone": "01012345678",
    "start_at": "2026-10-12T10:30:00+03:00",
    "end_at": "2026-10-12T11:00:00+03:00",
    "all_day": false,
    "title": "Dr. Mona Adel",
    "notes": "Chest pain follow-up",
    "status": "confirmed",
    "created_by": "api",
    "source": "api",
    "is_ai": false,
    "created_at": "2026-10-08T09:14:02+00:00",
    "updated_at": "2026-10-08T09:14:02+00:00"
  } ],
  "meta": { "current_page": 1, "last_page": 1, "per_page": 50, "total": 1, "timezone": "Africa/Cairo",
            "next_updated_since": "2026-10-08T09:14:02+00:00" }
}
Errors (4)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
422validation_failedA malformed date, an unknown status, or to before from.

Book

POST/api/v1/reservations

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).

ParameterInTypeDescription
customer_namerequiredbodystring ≤ 150Who the booking is for.
customer_phonebodystring ≤ 30Their number.
branch_idbodyintegergeneral: the branch (a doctor or room implies its own branch).
doctor_id / room_id / resource_idbodyintegermedical / hotel: the doctor or the room (aliases of one field).
start_atbodyISO-8601 or local "Y-m-d H:i"general / medical: the start; must be in the future.
end_atbodyISO-8601 or local "Y-m-d H:i"general: required; medical: defaults to one slot.
check_inbodyYYYY-MM-DDhotel: arrival day (today or later).
nightsbodyinteger 1–60hotel: default 1.
titlebodystring ≤ 200Defaults to the doctor / room, or the customer name.
notesbodystring ≤ 1000Anything for staff (complaint, guests, requests).
statusbodyenum: confirmed | pendingDefault confirmed; pending puts it in staff's "needs confirmation" queue.
external_refbodystring ≤ 100Your own id for the booking (unique per agent).
lead_idbodyintegerLink it to one of this agent's leads (from the leads feed or a chat).
departure_idbodyintegertourism: the departure (from setup / availability).
party_sizebodyinteger 1–200tourism: how many travelers (default 1).
allow_waitlistbodybooleantourism: default true — a booking that does not fit waits in line; false → 409 full.
Clinic — a doctor at 10:30
curl -X POST https://everysecond.ai/api/v1/reservations -H "Authorization: Bearer $EVERYSECOND_KEY" -H "Content-Type: application/json" \
  -d '{"doctor_id": 12, "start_at": "2026-10-12 10:30", "customer_name": "Ahmed Samir", "customer_phone": "01012345678", "notes": "Chest pain follow-up", "external_ref": "HIS-88421"}'
Hotel — room 101 for two nights
curl -X POST https://everysecond.ai/api/v1/reservations -H "Authorization: Bearer $EVERYSECOND_KEY" -H "Content-Type: application/json" \
  -d '{"room_id": 21, "check_in": "2026-10-12", "nights": 2, "customer_name": "Sara Nabil", "notes": "Guests: 2", "external_ref": "PMS-2210"}'
Restaurant — a table 19:00–20:30
curl -X POST https://everysecond.ai/api/v1/reservations -H "Authorization: Bearer $EVERYSECOND_KEY" -H "Content-Type: application/json" \
  -d '{"branch_id": 3, "start_at": "2026-10-12 19:00", "end_at": "2026-10-12 20:30", "customer_name": "Omar", "title": "Table for 4"}'
Tour operator — 3 travelers on a departure
curl -X POST https://everysecond.ai/api/v1/reservations -H "Authorization: Bearer $EVERYSECOND_KEY" -H "Content-Type: application/json" \
  -d '{"departure_id": 502, "party_size": 3, "customer_name": "Hany Fouad", "customer_phone": "01223344556", "external_ref": "TRV-1187"}'
tourism — 201 when the departure is full
{ "data": {
    "id": 640, "external_ref": "TRV-1187", "branch_id": 7, "branch_name": "Downtown office",
    "resource_id": 31,
    "resource": { "id": 31, "kind": "program", "name": "Siwa Oasis", "path": ["Siwa Oasis"], "meta": { "price": 4500, "duration": "3 days / 2 nights", "meeting_point": "Tahrir Square" } },
    "check_in": null, "check_out": null, "nights": null,
    "departure_id": 502,
    "departure": { "id": 502, "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00", "capacity": 20, "price": 3900.0 },
    "party_size": 3, "waitlist_position": 2, "promoted_at": null, "promotion_notice": null,
    "lead_id": null, "bot_id": null, "sender_id": null,
    "customer_name": "Hany Fouad", "customer_phone": "01223344556",
    "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00", "all_day": false,
    "title": "Siwa Oasis", "notes": "One vegetarian", "status": "waitlisted", "created_by": "api", "source": "api", "is_ai": false,
    "created_at": "2026-10-08T11:20:41+00:00", "updated_at": "2026-10-08T11:20:41+00:00"
  } }
201 Created
{ "data": {
    "id": 482,
    "external_ref": "HIS-88421",
    "branch_id": 3,
    "branch_name": "Nasr City",
    "resource_id": 12,
    "resource": {
      "id": 12, "kind": "doctor", "name": "Dr. Mona Adel",
      "path": ["Cardiology", "Clinic 2", "Dr. Mona Adel"],
      "meta": { "title": "Consultant", "fee": 500 }
    },
    "check_in": null, "check_out": null, "nights": null,
    "lead_id": 9182, "bot_id": null, "sender_id": null,
    "customer_name": "Ahmed Samir",
    "customer_phone": "01012345678",
    "start_at": "2026-10-12T10:30:00+03:00",
    "end_at": "2026-10-12T11:00:00+03:00",
    "all_day": false,
    "title": "Dr. Mona Adel",
    "notes": "Chest pain follow-up",
    "status": "confirmed",
    "created_by": "api",
    "source": "api",
    "is_ai": false,
    "created_at": "2026-10-08T09:14:02+00:00",
    "updated_at": "2026-10-08T09:14:02+00:00"
  } }
Errors (17)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
404not_foundThe branch is not one of this agent's.
409clashThe slot / room is taken — details.conflicts[] lists what it overlaps.
409duplicate_external_refThat external_ref is already used — details.id is the existing booking.
422outside_hoursOutside the branch hours or the doctor's timetable.
422resource_requiredmedical / hotel agent and no doctor / room given.
422resource_not_foundUnknown, switched-off, or another branch's doctor / room.
422pasthotel: the check-in day has passed.
422invalid_nightsFewer than 1 or more than 60 nights.
422invalid_rangeThe end is not after the start.
422validation_failedA missing field (e.g. end_at for a general booking, customer_name), a start in the past, or a malformed value.
409fulltourism with allow_waitlist: false: not enough seats — details.seats_left.
422departure_requiredtourism agent and no departure_id.
422departure_not_foundUnknown, switched-off, or another agent's departure (or its program is off).
422party_too_largeThe group is bigger than the departure's capacity — details.capacity.

Read one reservation

GET/api/v1/reservations/{id}

One booking by its EverySecond id.

BASH
curl https://everysecond.ai/api/v1/reservations/482 -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{ "data": {
    "id": 517, "external_ref": "PMS-2210", "branch_id": 5, "branch_name": "Downtown · Corniche",
    "resource_id": 21,
    "resource": { "id": 21, "kind": "room", "name": "101", "path": ["101"], "meta": { "room_type": "Deluxe", "capacity": 2, "price": 1500 } },
    "check_in": "2026-10-12", "check_out": "2026-10-14", "nights": 2,
    "customer_name": "Sara Nabil", "customer_phone": "01112223334",
    "start_at": "2026-10-12T12:00:00+03:00", "end_at": "2026-10-14T12:00:00+03:00", "all_day": false,
    "title": "101", "notes": "Guests: 2", "status": "pending", "created_by": "ai", "source": "ai", "is_ai": true,
    "created_at": "2026-10-08T10:02:11+00:00", "updated_at": "2026-10-08T10:02:11+00:00"
  } }
Errors (4)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
404not_foundNo such booking on this agent.

Read one by your own id

GET/api/v1/reservations/ref/{id}

One booking by the external_ref you gave it.

ParameterInTypeDescription
external_refrequiredpathstringYour own id, URL-encoded.
BASH
curl https://everysecond.ai/api/v1/reservations/ref/HIS-88421 -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{ "data": { "id": 482, "external_ref": "HIS-88421", "status": "confirmed", "…": "…" } }
Errors (4)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
404not_foundNo booking with that external_ref on this agent.

Move or edit

PATCH/api/v1/reservations/{id}

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.

ParameterInTypeDescription
start_at / end_atbodyISO-8601 or local "Y-m-d H:i"New time (general / medical).
resource_idbodyintegerAnother doctor / room of the same branch.
check_in / nightsbodyYYYY-MM-DD / integerNew stay dates (hotel).
departure_id / party_sizebodyinteger / integerAnother departure / another number of travelers (tourism).
customer_name / customer_phone / title / notesbodystringDetails.
external_refbodystring ≤ 100Set or change your own id.
BASH
curl -X PATCH https://everysecond.ai/api/v1/reservations/482 -H "Authorization: Bearer $EVERYSECOND_KEY" -H "Content-Type: application/json" -d '{"start_at": "2026-10-12 11:00"}'
Response
{ "data": { "id": 482, "status": "rescheduled", "start_at": "2026-10-12T11:00:00+03:00", "end_at": "2026-10-12T11:30:00+03:00", "…": "…" } }
Errors (11)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
404not_foundNo such booking on this agent.
409clashThe new time / room is taken.
409duplicate_external_refAnother booking already uses that ref.
409fulltourism: the new departure / bigger party does not fit.
422cancelledMoving a cancelled booking.
422outside_hoursOutside the hours / timetable.
422party_too_largetourism: more travelers than the departure can ever take.
422departure_requiredtourism: a time move (start_at) — move to another departure instead.

Confirm

POST/api/v1/reservations/{id}/confirm

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).

ParameterInTypeDescription
timebodyHH:MMLocal start for a day request (not for stays).
end_timebodyHH:MMDefault: one slot / the branch slot length.
BASH
curl -X POST https://everysecond.ai/api/v1/reservations/517/confirm -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{ "data": { "id": 517, "status": "confirmed", "…": "…" } }
Errors (7)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
404not_foundNo such booking on this agent.
409clashThe given time is taken.
409no_seatsA waiting booking whose departure has too few free seats — details.seats_left, details.needed.
422cancelledA cancelled booking cannot be confirmed.

Cancel

POST/api/v1/reservations/{id}/cancel

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"
Response
{ "data": { "id": 482, "status": "cancelled", "…": "…" } }
Errors (4)
StatusCodeWhen
401—Missing or invalid API key (body: {"error": "…"}).
403reservations_disabledThe account has not switched the Reservations add-on on (free, Finance → Bookings & orders).
429—More than 120 requests a minute on this key, shared by every /api/v1 surface; see Retry-After.
404not_foundNo such booking on this agent.

Keeping your system in sync

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);

Clinics, hotels and trips: what the agent does in chat

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.

Commerce

Orders

API key · Orders module on

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.

List orders

GET/api/v1/orders

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.

ParameterInTypeDescription
statusqueryenum: pending | confirmed | preparing | ready_for_pickup | out_for_delivery | delivered | cancelledOnly orders in this status.
branch_idqueryintegerOnly orders of this branch (must belong to the agent, else 404).
sincequeryISO-8601 stringOrders created at or after this moment, e.g. 2026-10-07T00:00:00Z.
since_idqueryintegerOrders with an id greater than this.
per_pagequeryinteger1–200, default 50 (out-of-range values are clamped).
pagequeryintegerPage number, default 1.
Everything after the last order you stored
curl "https://everysecond.ai/api/v1/orders?since_id=511&per_page=200" \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "id": 512,
      "project_id": 130,
      "branch": {
        "id": 7,
        "name": "Downtown"
      },
      "status": "confirmed",
      "merchant_ref": "SO-1042",
      "payment_url": "https://pay.example-burgers.com/SO-1042",
      "tracking_url": null,
      "requires_staff_approval": false,
      "awaiting_approval": false,
      "created_by": "ai",
      "channel": "whatsapp",
      "source": "chat",
      "store_order_id": null,
      "chat_id": null,
      "conversation_url": "https://everysecond.ai/leads/88",
      "customer": {
        "name": "Ahmed Samir",
        "phone": "01012345678",
        "phone_e164": "+201012345678",
        "language": "ar",
        "sender_id": "201012345678",
        "lead_id": 88
      },
      "fulfillment_type": "delivery",
      "address": "12 Nile St, building 4, flat 7, Maadi",
      "address_details": {
        "area": "Maadi",
        "street": "12 Nile St",
        "building": "4",
        "unit": "7"
      },
      "delivery_zone": "Maadi",
      "notes": "No onions, please",
      "items": [
        {
          "menu_item_id": 311,
          "external_id": "BRG-CLASSIC",
          "offer_id": null,
          "components": null,
          "name": "Classic Burger",
          "options": {
            "size": "Large"
          },
          "unit_price": 120.0,
          "qty": 2,
          "line_total": 240.0
        },
        {
          "menu_item_id": 312,
          "external_id": "DRK-COLA",
          "offer_id": null,
          "components": null,
          "name": "Cola 330ml",
          "options": null,
          "unit_price": 25.0,
          "qty": 2,
          "line_total": 50.0
        }
      ],
      "items_total": 290.0,
      "delivery_fee": 20.0,
      "total": 310.0,
      "currency": "EGP",
      "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
      "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
      "created_at": "2026-10-07T14:20:03+00:00",
      "updated_at": "2026-10-07T14:22:41+00:00"
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 3,
    "total": 128
  }
}
Errors (6)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Branch not foundbranch_id is not a branch of this agent: Branch 12 does not belong to this project.
422Bad sincesince could not be read as a date: since must be a date, e.g. 2026-07-28T00:00:00Z.
422Bad statusstatus is not one of the order statuses: status must be one of pending, confirmed, preparing, ready_for_pickup, out_for_delivery, delivered, cancelled.

Get an order

GET/api/v1/orders/{id}

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.

ParameterInTypeDescription
idrequiredpathintegerThe order id (order.id in payloads).
BASH
curl https://everysecond.ai/api/v1/orders/512 \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "pending",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": null,
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:20:03+00:00"
  }
}
Errors (4)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Order not foundNo order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".

Confirm an order

POST/api/v1/orders/{id}/confirm

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.

ParameterInTypeDescription
idrequiredpathintegerThe order id (order.id in payloads).
BASH
curl -X POST https://everysecond.ai/api/v1/orders/512/confirm \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "confirmed",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:22:41+00:00"
  }
}
Errors (5)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Order not foundNo order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".
409invalid_transitionThe 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.

Cancel an order

POST/api/v1/orders/{id}/cancel

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.reason status_unchanged, and sends nothing.

ParameterInTypeDescription
idrequiredpathintegerThe order id (order.id in payloads).
notify_customerbodybooleanDefault true. false cancels without messaging the customer (notification.reason: not_requested).
messagebodystring | nullUp 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."}'
Response with "notify_customer": false
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "cancelled",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": null,
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:22:41+00:00"
  },
  "notification": {
    "sent": false,
    "via": null,
    "reason": "not_requested"
  }
}
Response
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "cancelled",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": null,
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:22:41+00:00"
  },
  "notification": {
    "sent": true,
    "via": "chat"
  }
}
Errors (6)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Order not foundNo order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".
422Validation failed{"error": "Validation failed.", "details": ["notify_customer must be true or false."]} — also for a message over 1000 characters.
409invalid_transitionThe 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.

Report an order status

POST/api/v1/orders/{id}/status

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.

ParameterInTypeDescription
idrequiredpathintegerThe order id (order.id in payloads).
statusrequiredbodyenum: confirmed | preparing | ready_for_pickup | out_for_delivery | delivered | cancelledThe 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_urlbodystring (URL) | nullUp 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_refbodystring | nullYour order number, up to 191 characters, unique on this agent (409 if another order has it). null clears it.
notify_customerbodybooleanDefault true: tell the customer in the conversation they ordered in. Only a real status change is announced.
messagebodystring | nullUp 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).
forcebodybooleanDefault 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
curl -X POST https://everysecond.ai/api/v1/orders/512/status \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "status": "out_for_delivery",
        "tracking_url": "https://track.example-burgers.com/SO-1042",
        "merchant_ref": "SO-1042",
        "notify_customer": true
      }'
Response when the 24-hour window had closed (WhatsApp template sent)
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "out_for_delivery",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": "https://track.example-burgers.com/SO-1042",
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:51:09+00:00"
  },
  "notification": {
    "sent": true,
    "via": "template",
    "campaign_id": 4417
  }
}
Response for a Messenger order after 24 hours (status saved, customer not told)
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "out_for_delivery",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": "https://track.example-burgers.com/SO-1042",
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:51:09+00:00"
  },
  "notification": {
    "sent": false,
    "via": null,
    "reason": "outside_messaging_window"
  }
}
409 for a step back without force
{
  "error": "Order 512 is delivered and cannot move to preparing: statuses only move forward. Send \"force\": true to correct a mistake.",
  "code": "invalid_transition",
  "details": {
    "status": "delivered",
    "requested": "preparing",
    "allowed": []
  }
}
Correct a mistake (marked delivered too early) — the customer is not messaged
curl -X POST https://everysecond.ai/api/v1/orders/512/status \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "out_for_delivery", "force": true}'
Response to the forced correction
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "out_for_delivery",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": "https://track.example-burgers.com/SO-1042",
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:51:09+00:00"
  },
  "notification": {
    "sent": false,
    "via": null,
    "reason": "forced_correction"
  }
}
Response
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "out_for_delivery",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": "https://track.example-burgers.com/SO-1042",
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:51:09+00:00"
  },
  "notification": {
    "sent": true,
    "via": "chat"
  }
}
Errors (9)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Order not foundNo order with that id on this agent (another agent's order answers the same): Order 999 was not found for project "Downtown Burgers".
422Validation 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.
409invalid_transitionThe 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.
422Awaiting staff approvalThe 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).
422Wrong fulfilment statusout_for_delivery on a pickup order ("use ready_for_pickup"), or ready_for_pickup on a delivery order ("use out_for_delivery").
409merchant_ref takenAnother order of this agent already has that merchant_ref. Nothing was changed.

Get an order by your order number

GET/api/v1/orders/ref/{id}

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.

ParameterInTypeDescription
idrequiredpathstringYour order number (merchant_ref), URL-encoded; cannot contain /.
BASH
curl https://everysecond.ai/api/v1/orders/ref/SO-1042 \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "pending",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": null,
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:20:03+00:00"
  }
}
Errors (4)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Ref not foundNo order on this agent carries that merchant_ref: No order with merchant_ref "SO-1042" on this agent.

Report a status by your order number

POST/api/v1/orders/ref/{id}/status

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.

ParameterInTypeDescription
idrequiredpathstringYour order number (merchant_ref), URL-encoded; cannot contain /.
statusrequiredbodyenum: confirmed | preparing | ready_for_pickup | out_for_delivery | delivered | cancelledThe 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_urlbodystring (URL) | nullUp 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_refbodystring | nullYour order number, up to 191 characters, unique on this agent (409 if another order has it). null clears it.
notify_customerbodybooleanDefault true: tell the customer in the conversation they ordered in. Only a real status change is announced.
messagebodystring | nullUp 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).
forcebodybooleanDefault 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.
BASH
curl -X POST https://everysecond.ai/api/v1/orders/ref/SO-1042/status \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "delivered"}'
Response
{
  "data": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "delivered",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T15:18:30+00:00"
  },
  "notification": {
    "sent": true,
    "via": "chat"
  }
}
Errors (9)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Ref not foundNo order on this agent carries that merchant_ref: No order with merchant_ref "SO-1042" on this agent.
422Validation 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.
409invalid_transitionThe 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.
422Awaiting staff approvalThe 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).
422Wrong fulfilment statusout_for_delivery on a pickup order ("use ready_for_pickup"), or ready_for_pickup on a delivery order ("use out_for_delivery").
409merchant_ref takenAnother order of this agent already has that merchant_ref. Nothing was changed.

Get status templates

GET/api/v1/orders/status-templates

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).

BASH
curl https://everysecond.ai/api/v1/orders/status-templates \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "confirmed": null,
    "preparing": null,
    "ready_for_pickup": {
      "template_id": 14,
      "name": "order_ready_for_pickup",
      "language": "ar",
      "status": "approved"
    },
    "out_for_delivery": {
      "template_id": 12,
      "name": "order_out_for_delivery",
      "language": "ar",
      "status": "approved"
    },
    "delivered": {
      "template_id": 15,
      "name": "order_delivered_thanks",
      "language": "ar",
      "status": "approved"
    },
    "cancelled": null
  }
}
Errors (3)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.

Map status templates

PUT/api/v1/orders/status-templates

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.

ParameterInTypeDescription
templatesrequiredbodyobjectStatus → template id or null. Keys: confirmed, preparing, ready_for_pickup, out_for_delivery, delivered, cancelled.
BASH
curl -X PUT https://everysecond.ai/api/v1/orders/status-templates \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"templates": {"out_for_delivery": 12, "ready_for_pickup": 14, "delivered": 15, "preparing": null}}'
Response
{
  "data": {
    "confirmed": null,
    "preparing": null,
    "ready_for_pickup": {
      "template_id": 14,
      "name": "order_ready_for_pickup",
      "language": "ar",
      "status": "approved"
    },
    "out_for_delivery": {
      "template_id": 12,
      "name": "order_out_for_delivery",
      "language": "ar",
      "status": "approved"
    },
    "delivered": {
      "template_id": 15,
      "name": "order_delivered_thanks",
      "language": "ar",
      "status": "approved"
    },
    "cancelled": null
  }
}
Errors (6)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
422Bad templatestemplates is missing or not an object: templates must be an object of status → template id (or null).
422Unknown statusA key is not one of the six statuses: "shipped" is not a status. Use one of: confirmed, preparing, …
404Template not foundTemplate 99 was not found for this account. GET /api/v1/whatsapp/templates lists them.

List carts

GET/api/v1/orders/carts

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).

ParameterInTypeDescription
statusqueryenum: active | converted | closed | allDefault active. Any other value is refused with 422.
limitqueryinteger1–500, default 100.
Open baskets (abandoned-cart follow-up)
curl "https://everysecond.ai/api/v1/orders/carts?status=active&limit=200" \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "id": 77,
      "project_id": 130,
      "branch_id": 7,
      "lead_id": 88,
      "sender_id": "201012345678",
      "customer_name": "Ahmed Samir",
      "status": "active",
      "items": [
        {
          "line_id": "item:311",
          "menu_item_id": 311,
          "external_id": "BRG-CLASSIC",
          "offer_id": null,
          "name": "Classic Burger",
          "options": {
            "size": "Large"
          },
          "unit_price": 120.0,
          "qty": 2,
          "line_total": 240.0
        },
        {
          "line_id": "item:312",
          "menu_item_id": 312,
          "external_id": "DRK-COLA",
          "offer_id": null,
          "name": "Cola 330ml",
          "unit_price": 25.0,
          "qty": 2,
          "line_total": 50.0
        }
      ],
      "items_total": 290.0,
      "merchant_quote": {
        "stage": "quote",
        "ok": true,
        "message": null,
        "ms": 412,
        "delivery_id": 90211,
        "at": "2026-10-07T14:12:41+00:00",
        "items": [
          {
            "line_id": "item:311",
            "menu_item_id": 311,
            "external_id": "BRG-CLASSIC",
            "offer_id": null,
            "unit_price": 115.0,
            "available_qty": 25
          },
          {
            "line_id": "item:312",
            "menu_item_id": 312,
            "external_id": "DRK-COLA",
            "offer_id": null,
            "available_qty": 40
          }
        ],
        "delivery_fee": 15.0,
        "total": 295.0,
        "shortage": null
      },
      "converted_order_id": null,
      "changed_by": "agent",
      "closed_at": null,
      "closed_reason": null,
      "last_activity_at": "2026-10-07T14:12:40+00:00",
      "created_at": "2026-10-07T14:05:11+00:00"
    }
  ]
}
Errors (4)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
422Bad statusstatus must be one of active, converted, closed, all.

List branches

GET/api/v1/orders/branches

The branches of this agent — the branch_id values used everywhere else.

BASH
curl https://everysecond.ai/api/v1/orders/branches \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "id": 7,
      "name": "Downtown",
      "address": "5 Talaat Harb St, Cairo",
      "phone": "+20225761234"
    },
    {
      "id": 9,
      "name": "Maadi",
      "address": "Road 9, Maadi, Cairo",
      "phone": "+20223581234"
    }
  ]
}
Errors (3)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.

Get a branch menu

GET/api/v1/orders/branches/{id}/menu

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.

ParameterInTypeDescription
idrequiredpathintegerA branch id of this agent.
BASH
curl https://everysecond.ai/api/v1/orders/branches/7/menu \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "id": 311,
      "external_id": "BRG-CLASSIC",
      "name": "Classic Burger",
      "name_ar": "برجر كلاسيك",
      "description": "150 g beef patty, cheddar, house sauce",
      "category": "Burgers",
      "price": 130.0,
      "offer_price": 120.0,
      "effective_price": 120.0,
      "is_available": true,
      "stock_qty": null,
      "option_group": "Classic Burger",
      "options": {
        "size": "Large"
      },
      "image_url": "https://everysecond.ai/storage/menu-items/synced/130/k3V9qX2mT7bL0pR5sW8yZ1nC4dF6gH2jK9aE3uI0.jpg",
      "product_url": "https://example-burgers.com/menu/classic-burger",
      "orderable_in_chat": true,
      "max_qty_per_order": 10,
      "requires_staff_approval": false
    },
    {
      "id": 205,
      "external_id": null,
      "name": "Fries",
      "name_ar": null,
      "description": null,
      "category": "Sides",
      "price": 35.0,
      "offer_price": null,
      "effective_price": 35.0,
      "is_available": true,
      "stock_qty": 40,
      "option_group": null,
      "options": null,
      "image_url": null,
      "product_url": null,
      "orderable_in_chat": true,
      "max_qty_per_order": null,
      "requires_staff_approval": false
    }
  ],
  "currency": "EGP"
}
Errors (4)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Branch not foundBranch 12 does not belong to this project.

List synced menu items

GET/api/v1/orders/menu/items

Your synced catalogue, one entry per external_id, with the menu_item_id it has in each branch.

Only items that have an external_id (hand-made dashboard items are not listed). Sorted by external_id.

An updated_since value that cannot be read as a date is refused with 422 (it is never silently ignored).

ParameterInTypeDescription
updated_sincequeryISO-8601 stringOnly items changed at or after this moment.
per_pagequeryinteger1–500, default 100.
pagequeryintegerPage number, default 1.
BASH
curl "https://everysecond.ai/api/v1/orders/menu/items?updated_since=2026-10-06T00:00:00Z&per_page=500" \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "external_id": "BRG-CLASSIC",
      "name": {
        "en": "Classic Burger",
        "ar": "برجر كلاسيك"
      },
      "description": {
        "en": "150 g beef patty, cheddar, house sauce",
        "ar": "قطعة لحم ١٥٠ جم، شيدر، صوص البيت"
      },
      "category": "Burgers",
      "price": 130.0,
      "offer_price": 120.0,
      "available": true,
      "stock_qty": null,
      "image_url": "https://cdn.example-burgers.com/img/brg-classic.jpg",
      "product_url": "https://example-burgers.com/menu/classic-burger",
      "option_group": "Classic Burger",
      "options": {
        "size": "Large"
      },
      "orderable_in_chat": true,
      "max_qty_per_order": 10,
      "requires_staff_approval": false,
      "branches": [
        {
          "branch_id": 7,
          "menu_item_id": 311,
          "effective_price": 120.0,
          "image": "https://everysecond.ai/storage/menu-items/synced/130/k3V9qX2mT7bL0pR5sW8yZ1nC4dF6gH2jK9aE3uI0.jpg"
        },
        {
          "branch_id": 9,
          "menu_item_id": 344,
          "effective_price": 120.0,
          "image": "https://everysecond.ai/storage/menu-items/synced/130/Pq8wE2rT6yU0iO4pA7sD1fG5hJ9kL3zX6cV2bN8m.jpg"
        }
      ],
      "updated_at": "2026-10-07T09:15:02+00:00"
    }
  ],
  "currency": "EGP",
  "meta": {
    "current_page": 1,
    "last_page": 1,
    "total": 1
  }
}
Errors (4)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
422Bad updated_sinceupdated_since must be a date, e.g. 2026-07-28T00:00:00Z.

Get a synced menu item

GET/api/v1/orders/menu/items/{id}

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).

ParameterInTypeDescription
idrequiredpathstringYour product id (external_id), URL-encoded. 1–191 characters, no /.
BASH
curl https://everysecond.ai/api/v1/orders/menu/items/BRG-CLASSIC \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "external_id": "BRG-CLASSIC",
    "name": {
      "en": "Classic Burger",
      "ar": "برجر كلاسيك"
    },
    "description": {
      "en": "150 g beef patty, cheddar, house sauce",
      "ar": "قطعة لحم ١٥٠ جم، شيدر، صوص البيت"
    },
    "category": "Burgers",
    "price": 130.0,
    "offer_price": 120.0,
    "available": true,
    "stock_qty": null,
    "image_url": "https://cdn.example-burgers.com/img/brg-classic.jpg",
    "product_url": "https://example-burgers.com/menu/classic-burger",
    "option_group": "Classic Burger",
    "options": {
      "size": "Large"
    },
    "orderable_in_chat": true,
    "max_qty_per_order": 10,
    "requires_staff_approval": false,
    "branches": [
      {
        "branch_id": 7,
        "menu_item_id": 311,
        "effective_price": 120.0,
        "image": "https://everysecond.ai/storage/menu-items/synced/130/k3V9qX2mT7bL0pR5sW8yZ1nC4dF6gH2jK9aE3uI0.jpg"
      },
      {
        "branch_id": 9,
        "menu_item_id": 344,
        "effective_price": 120.0,
        "image": "https://everysecond.ai/storage/menu-items/synced/130/Pq8wE2rT6yU0iO4pA7sD1fG5hJ9kL3zX6cV2bN8m.jpg"
      }
    ],
    "updated_at": "2026-10-07T09:15:02+00:00"
  }
}
Errors (4)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Item not foundNo menu item with that external_id on this agent.

Create or merge a menu item

PUT/api/v1/orders/menu/items/{id}

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.

ParameterInTypeDescription
idrequiredpathstringYour product id (external_id), URL-encoded. 1–191 characters, no /.
namerequiredbodystring | 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.
pricerequiredbodynumberList price, 0 to 99,999,999, rounded to 2 decimals. Cannot be null.
descriptionbodystring | object {en, ar} | nullSame shape as name, each language up to 5000 characters.
categorybodystring | nullUp to 191 characters. Helps the agent search large catalogues.
offer_pricebodynumber | nullA 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.
availablebodybooleanfalse hides the item from the agent at once. Accepts true/false, 1/0, "yes"/"no", "on"/"off".
stock_qtybodyinteger | nullWhole 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_urlbodystring (http/https URL) | nullUp 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_urlbodystring (http/https URL) | nullUp to 2048 characters. Where a tap on the product card photo leads.
option_groupbodystring | nullUp to 191 characters. Variants of one product (sizes, flavours) share a group; each variant is its own item with its own id and price.
optionsbodyobject | nullUp 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.
branchesbody"all" | array<integer> | integerBranches 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_chatbodybooleanDefault true. false: the agent can talk about the item but never puts it in a basket or an order.
max_qty_per_orderbodyinteger | null1 to 100000, or null for no limit. The basket is capped at it and a larger chat order is refused.
requires_staff_approvalbodybooleanOrders containing the item are taken but stay awaiting_approval until staff confirm; fulfilment statuses are refused until then.
BASH
curl -X PUT https://everysecond.ai/api/v1/orders/menu/items/BRG-CLASSIC \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": {"en": "Classic Burger", "ar": "برجر كلاسيك"},
        "description": {"en": "150 g beef patty, cheddar, house sauce"},
        "category": "Burgers",
        "price": 130,
        "offer_price": 120,
        "available": true,
        "stock_qty": null,
        "image_url": "https://cdn.example-burgers.com/img/brg-classic.jpg",
        "product_url": "https://example-burgers.com/menu/classic-burger",
        "option_group": "Classic Burger",
        "options": {"size": "Large"},
        "branches": "all",
        "orderable_in_chat": true,
        "max_qty_per_order": 10,
        "requires_staff_approval": false
      }'
Response
{
  "data": {
    "external_id": "BRG-CLASSIC",
    "name": {
      "en": "Classic Burger",
      "ar": "برجر كلاسيك"
    },
    "description": {
      "en": "150 g beef patty, cheddar, house sauce",
      "ar": "قطعة لحم ١٥٠ جم، شيدر، صوص البيت"
    },
    "category": "Burgers",
    "price": 130.0,
    "offer_price": 120.0,
    "available": true,
    "stock_qty": null,
    "image_url": "https://cdn.example-burgers.com/img/brg-classic.jpg",
    "product_url": "https://example-burgers.com/menu/classic-burger",
    "option_group": "Classic Burger",
    "options": {
      "size": "Large"
    },
    "orderable_in_chat": true,
    "max_qty_per_order": 10,
    "requires_staff_approval": false,
    "branches": [
      {
        "branch_id": 7,
        "menu_item_id": 311,
        "effective_price": 120.0,
        "image": "https://everysecond.ai/storage/menu-items/synced/130/k3V9qX2mT7bL0pR5sW8yZ1nC4dF6gH2jK9aE3uI0.jpg"
      },
      {
        "branch_id": 9,
        "menu_item_id": 344,
        "effective_price": 120.0,
        "image": "https://everysecond.ai/storage/menu-items/synced/130/Pq8wE2rT6yU0iO4pA7sD1fG5hJ9kL3zX6cV2bN8m.jpg"
      }
    ],
    "updated_at": "2026-10-07T09:15:02+00:00"
  },
  "result": "created"
}
Errors (7)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
422Validation failedEvery 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."]}
422Bad external_idexternal_id must be 1–191 characters with no "/".
422Bad branchesbranches must be "all" or a list of branch ids., or Branch 99 does not belong to this agent.
422No branchThe agent has no branch yet — create one on the Orders screen first.

Change part of a menu item

PATCH/api/v1/orders/menu/items/{id}

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.

ParameterInTypeDescription
idrequiredpathstringYour product id (external_id), URL-encoded. 1–191 characters, no /.
namebodystring | 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.
pricebodynumberList price, 0 to 99,999,999, rounded to 2 decimals. Cannot be null.
descriptionbodystring | object {en, ar} | nullSame shape as name, each language up to 5000 characters.
categorybodystring | nullUp to 191 characters. Helps the agent search large catalogues.
offer_pricebodynumber | nullA 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.
availablebodybooleanfalse hides the item from the agent at once. Accepts true/false, 1/0, "yes"/"no", "on"/"off".
stock_qtybodyinteger | nullWhole 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_urlbodystring (http/https URL) | nullUp 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_urlbodystring (http/https URL) | nullUp to 2048 characters. Where a tap on the product card photo leads.
option_groupbodystring | nullUp to 191 characters. Variants of one product (sizes, flavours) share a group; each variant is its own item with its own id and price.
optionsbodyobject | nullUp 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.
branchesbody"all" | array<integer> | integerBranches 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_chatbodybooleanDefault true. false: the agent can talk about the item but never puts it in a basket or an order.
max_qty_per_orderbodyinteger | null1 to 100000, or null for no limit. The basket is capped at it and a larger chat order is refused.
requires_staff_approvalbodybooleanOrders containing the item are taken but stay awaiting_approval until staff confirm; fulfilment statuses are refused until then.
Sold out right now
curl -X PATCH https://everysecond.ai/api/v1/orders/menu/items/BRG-CLASSIC \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"available": false}'
New price in one branch
curl -X PATCH https://everysecond.ai/api/v1/orders/menu/items/BRG-CLASSIC \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"price": 140, "branches": [9]}'
Response
{
  "data": {
    "external_id": "BRG-CLASSIC",
    "name": {
      "en": "Classic Burger",
      "ar": "برجر كلاسيك"
    },
    "description": {
      "en": "150 g beef patty, cheddar, house sauce",
      "ar": "قطعة لحم ١٥٠ جم، شيدر، صوص البيت"
    },
    "category": "Burgers",
    "price": 130.0,
    "offer_price": 120.0,
    "available": false,
    "stock_qty": null,
    "image_url": "https://cdn.example-burgers.com/img/brg-classic.jpg",
    "product_url": "https://example-burgers.com/menu/classic-burger",
    "option_group": "Classic Burger",
    "options": {
      "size": "Large"
    },
    "orderable_in_chat": true,
    "max_qty_per_order": 10,
    "requires_staff_approval": false,
    "branches": [
      {
        "branch_id": 7,
        "menu_item_id": 311,
        "effective_price": 120.0,
        "image": "https://everysecond.ai/storage/menu-items/synced/130/k3V9qX2mT7bL0pR5sW8yZ1nC4dF6gH2jK9aE3uI0.jpg"
      },
      {
        "branch_id": 9,
        "menu_item_id": 344,
        "effective_price": 120.0,
        "image": "https://everysecond.ai/storage/menu-items/synced/130/Pq8wE2rT6yU0iO4pA7sD1fG5hJ9kL3zX6cV2bN8m.jpg"
      }
    ],
    "updated_at": "2026-10-07T15:02:44+00:00"
  },
  "result": "updated"
}
Errors (8)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Item not foundNo menu item with that external_id on this agent.
422Validation failedEvery 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."]}
422Bad external_idexternal_id must be 1–191 characters with no "/".
422Bad branchesbranches must be "all" or a list of branch ids., or Branch 99 does not belong to this agent.
422No branchThe agent has no branch yet — create one on the Orders screen first.

Delete a menu item

DELETE/api/v1/orders/menu/items/{id}

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.

ParameterInTypeDescription
idrequiredpathstringYour product id (external_id), URL-encoded. 1–191 characters, no /.
branchesquerystringComma-separated branch ids (3,5) or all (default).
Only from branch 9
curl -X DELETE "https://everysecond.ai/api/v1/orders/menu/items/BRG-CLASSIC?branches=9" \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "external_id": "BRG-CLASSIC",
    "deleted": true,
    "branches_removed": 2
  }
}
Errors (5)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
404Item not foundNo item with that external_id (in the named branches) on this agent.
422Bad branchesBranch 99 does not belong to this agent.

Batch menu sync

POST/api/v1/orders/menu/items/batch

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).

ParameterInTypeDescription
itemsrequiredbodyarray<object>Non-empty list of at most 500 entries.
items[].opbodyenum: upsert | patch | deleteDefault upsert.
items[].external_idrequiredbodystringYour product id, 1–191 characters, no /.
items[].…bodymixedThe item fields of PUT / PATCH (name, price, available, stock_qty, branches, …).
BASH
curl -X POST https://everysecond.ai/api/v1/orders/menu/items/batch \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "items": [
          {"op": "upsert", "external_id": "BRG-CLASSIC", "name": "Classic Burger", "price": 130, "category": "Burgers"},
          {"op": "patch", "external_id": "DRK-COLA", "stock_qty": 48},
          {"op": "upsert", "external_id": "BRG-SPICY", "name": "Spicy Burger"},
          {"op": "delete", "external_id": "DST-BROWNIE"}
        ]
      }'
Response
{
  "data": [
    {
      "index": 0,
      "external_id": "BRG-CLASSIC",
      "op": "upsert",
      "status": "updated",
      "menu_item_ids": [
        311,
        344
      ]
    },
    {
      "index": 1,
      "external_id": "DRK-COLA",
      "op": "patch",
      "status": "updated",
      "menu_item_ids": [
        312,
        345
      ]
    },
    {
      "index": 2,
      "external_id": "BRG-SPICY",
      "op": "upsert",
      "status": "error",
      "error": "price is required."
    },
    {
      "index": 3,
      "external_id": "DST-BROWNIE",
      "op": "delete",
      "status": "deleted"
    }
  ],
  "summary": {
    "total": 4,
    "succeeded": 3,
    "failed": 1
  }
}
Errors (5)
StatusCodeWhen
401Missing or invalid API keyNo key in Authorization: Bearer / X-Api-Key, or the key matches no agent.
403Orders module offThe 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."}
429Rate limitedMore than 120 requests in a minute on this key. Wait the Retry-After seconds.
422Empty batchitems must be a non-empty list.
422Batch too largeA batch takes at most 500 items. Nothing is written.

Connect your own store or POS

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.

Let your system approve orders: order.validate

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 place merchant_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.

What your endpoint receives (stage place)
{
  "id": "0d8f6c3e-5b7a-4f1e-9a42-6c1d2e8b7f10",
  "event": "order.validate",
  "created_at": "2026-10-07T14:20:03+00:00",
  "project": {
    "id": 130,
    "name": "Downtown Burgers"
  },
  "data": {
    "stage": "place",
    "branch_id": 7,
    "currency": "EGP",
    "fulfillment": {
      "type": "delivery",
      "zone": "Maadi",
      "address": "12 Nile St, building 4, flat 7, Maadi",
      "address_details": {
        "area": "Maadi",
        "street": "12 Nile St",
        "building": "4",
        "unit": "7"
      }
    },
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "channel": "whatsapp",
    "items": [
      {
        "external_id": "BRG-CLASSIC",
        "menu_item_id": 311,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "qty": 2,
        "unit_price": 120.0
      },
      {
        "external_id": "DRK-COLA",
        "menu_item_id": 312,
        "name": "Cola 330ml",
        "qty": 2,
        "unit_price": 25.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0
  }
}
Accept, with your price, fee, order number and payment link
{
  "ok": true,
  "items": [
    {
      "external_id": "BRG-CLASSIC",
      "unit_price": 115.0,
      "available_qty": 25
    },
    {
      "external_id": "DRK-COLA",
      "available_qty": 40
    }
  ],
  "delivery_fee": 15.0,
  "total": 295.0,
  "merchant_ref": "SO-1042",
  "payment_url": "https://pay.example-burgers.com/SO-1042",
  "message": null
}
Refuse (the message is told to the customer)
{
  "ok": false,
  "message": "Sorry, Maadi deliveries stop at 11 pm. Pickup from Downtown is still open."
}
Accept the basket but report a shortage
{
  "ok": true,
  "items": [
    {
      "external_id": "BRG-CLASSIC",
      "available_qty": 1,
      "message": "Only one Classic Burger left today."
    }
  ]
}
Node / Express handler
// Mount with express.raw so the signature is checked over the exact bytes sent.
app.post('/hooks/everysecond', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifySignature(req)) return res.sendStatus(400); // X-EverySecond-Signature, see Webhooks
  const event = JSON.parse(req.body);
  if (event.event !== 'order.validate') return res.sendStatus(200); // other events: store and ack

  const { stage, items, delivery_fee } = event.data;
  const stock = await inventory.levels(items.map((i) => i.external_id)); // must answer well within 4 s

  const short = items.filter((i) => i.external_id && (stock[i.external_id] ?? 0) < i.qty);
  if (short.length) {
    return res.json({
      ok: true,
      items: short.map((i) => ({ external_id: i.external_id, available_qty: stock[i.external_id] ?? 0 })),
    });
  }

  if (stage === 'quote') return res.json({ ok: true, delivery_fee });

  const sale = await shop.createOrder(event.data); // stage "place": your order number + payment link
  return res.json({ ok: true, merchant_ref: sale.number, payment_url: sale.paymentLink });
});

Order statuses and customer notifications

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.

Map templates once, then report statuses
curl -X PUT https://everysecond.ai/api/v1/orders/status-templates \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"templates": {"out_for_delivery": 12, "delivered": 15}}'

curl -X POST https://everysecond.ai/api/v1/orders/512/status \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "preparing"}'

merchant_ref: your order number

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.)

Messaging

WhatsApp Outbound

API key · WhatsApp outbound on

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.

List sendable templates

GET/api/v1/whatsapp/templates

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.

BASH
curl -s https://everysecond.ai/api/v1/whatsapp/templates \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "id": 19,
      "name": "order_shipped",
      "category": "UTILITY",
      "language": "en_US",
      "status": "approved",
      "approved_on_waba_ids": ["102345678901234"],
      "credits_per_message": 1
    },
    {
      "id": 23,
      "name": "login_code",
      "category": "AUTHENTICATION",
      "language": "ar",
      "status": "approved",
      "approved_on_waba_ids": ["102345678901234"],
      "credits_per_message": 1
    },
    {
      "id": 12,
      "name": "october_promo",
      "category": "MARKETING",
      "language": "ar",
      "status": "approved",
      "approved_on_waba_ids": ["102345678901234"],
      "credits_per_message": 2
    }
  ],
  "project": { "id": 128, "name": "Cairo Bites" }
}
Errors (3)
StatusCodeWhen
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Template schema

GET/api/v1/whatsapp/templates/{id}/schema

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.

ParameterInTypeDescription
idrequiredpathintegerTemplate id from GET /templates.
BASH
curl -s https://everysecond.ai/api/v1/whatsapp/templates/19/schema \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "id": 19,
    "name": "order_shipped",
    "category": "UTILITY",
    "language": "en_US",
    "status": "approved",
    "body_text": "Hi {{1}}, your order {{2}} is on its way. Total: {{3}}.",
    "variables": [
      { "key": "1", "label": "Customer name", "example": "Ahmed", "required": true },
      { "key": "2", "label": null, "example": "RC-10293", "required": true },
      { "key": "3", "label": null, "example": "1450 EGP", "required": true }
    ],
    "header": {
      "type": "TEXT",
      "media_required": false,
      "text": "Order {{1}}",
      "variables": [
        { "key": "1", "input": "header_variables.1", "example": "RC-10293", "required": true }
      ]
    },
    "button": { "has_dynamic_url": true, "required": true },
    "buttons": [
      {
        "index": 1,
        "type": "URL",
        "text": "Track",
        "url": "https://shop.example/track/{{1}}",
        "input": "button_urls.1",
        "alias": "button_url",
        "example": "https://shop.example/track/ABC123",
        "required": true
      },
      {
        "index": 2,
        "type": "URL",
        "text": "Pay",
        "url": "https://shop.example/pay/{{1}}",
        "input": "button_urls.2",
        "alias": null,
        "example": "https://shop.example/pay/ABC123",
        "required": true
      }
    ],
    "credits_per_message": 1,
    "example_recipient": {
      "phone": "+201012345678",
      "name": "optional",
      "variables": { "1": "Ahmed", "2": "RC-10293", "3": "1450 EGP" },
      "header_variables": { "1": "RC-10293" },
      "button_urls": {
        "1": "https://shop.example/track/ABC123",
        "2": "https://shop.example/pay/ABC123"
      }
    }
  }
}
Errors (4)
StatusCodeWhen
404Template {id} was not found for project "…".The template does not exist or belongs to another project. Call GET /templates to see the ids this key can use.
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

List connected numbers

GET/api/v1/whatsapp/accounts

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.

BASH
curl -s https://everysecond.ai/api/v1/whatsapp/accounts \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "id": 41,
      "display_phone_number": "+20 10 1234 5678",
      "phone_number_id": "109876543210987",
      "waba_id": "102345678901234",
      "is_default": true
    }
  ],
  "project": { "id": 128, "name": "Cairo Bites" }
}
Errors (3)
StatusCodeWhen
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Credit balance

GET/api/v1/whatsapp/credits

Returns the wallet balance and the price of one message in each template category.

The wallet belongs to the account that owns the project, so every project and key of that owner shares one balance.

balance only counts credits that have not expired. Each purchase or grant expires on its own date.

The platform sets cost_per_message for each category, and 0 means messages in that category are free. Any other category is priced as UTILITY.

A campaign needs cost_per_message[category] × total_recipients credits. Call this before a large send so you can fail early.

BASH
curl -s https://everysecond.ai/api/v1/whatsapp/credits \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "balance": 4232,
    "cost_per_message": { "MARKETING": 2, "UTILITY": 1, "AUTHENTICATION": 1 }
  }
}
Errors (3)
StatusCodeWhen
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Send (create and queue)

POST/api/v1/whatsapp/send

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).

ParameterInTypeDescription
namerequiredbodystringLabel for the campaign, shown in the dashboard and in reports. Max 255 characters.
template_idrequiredbodyintegerAn 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_idbodyintegerThe sending number, an id from GET /accounts. Leave it out to use the project's default number (the one flagged is_default).
recipientsrequiredbodyarray<object>Between 1 and 5000 recipient objects.
recipients[].phonerequiredbodystringLocal 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_codebodystringDial 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[].namebodystringDisplay label in reports, max 255 characters. It is not inserted into the message. To greet someone by name, pass the name in variables.
recipients[].variablesbodyobjectRequired 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_variablesbodyobjectRequired 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_urlbodystring (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_urlbodystringValue 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_urlsbodyobjectOne 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_phonesbodybooleanDefaults 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.
Send one transactional message
curl -s -X POST https://everysecond.ai/api/v1/whatsapp/send \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Order RC-10293",
  "template_id": 19,
  "recipients": [
    {
      "phone": "01207256335",
      "name": "Ahmed",
      "variables": { "1": "Ahmed", "2": "RC-10293", "3": "1450 EGP" },
      "header_media_url": "https://cdn.example.com/orders/RC-10293.jpg",
      "button_url": "RC-10293"
    }
  ]
}'
Mixed list: send to the valid numbers, report the rest
curl -s -X POST https://everysecond.ai/api/v1/whatsapp/send \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "October promo",
    "template_id": 12,
    "skip_invalid_phones": true,
    "recipients": [
      { "phone": "+205558852225", "variables": { "1": "Mona" } },
      { "phone": "01207256335",   "variables": { "1": "Ahmed" } },
      { "phone": "+44 7911 123456", "variables": { "1": "Sara" } }
    ]
  }'
201 with skip_invalid_phones
{
  "data": {
    "id": 89,
    "name": "October promo",
    "status": "queued",
    "template_id": 12,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 2,
    "sent": 0,
    "delivered": 0,
    "read": 0,
    "failed": 0,
    "progress_percent": 0,
    "created_at": "2026-10-07T09:20:41+00:00",
    "completed_at": null,
    "skipped": [
      { "index": 0, "phone": "+205558852225", "error": "Not a valid number for country code +20" }
    ]
  }
}
Login codes: let EverySecond generate them
curl -s -X POST https://everysecond.ai/api/v1/whatsapp/send \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Login codes",
    "template_id": 23,
    "recipients": [
      { "phone": "+966501234567" },
      { "phone": "+966501234568", "variables": { "1": "424242" } }
    ]
  }'
201 for an AUTHENTICATION template
{
  "data": {
    "id": 91,
    "name": "Login codes",
    "status": "queued",
    "template_id": 23,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 2,
    "sent": 0,
    "delivered": 0,
    "read": 0,
    "failed": 0,
    "progress_percent": 0,
    "created_at": "2026-10-07T09:31:00+00:00",
    "completed_at": null,
    "otp_codes": [
      { "index": 0, "recipient_id": 5101, "phone": "966501234567", "code": "482913", "generated": true },
      { "index": 1, "recipient_id": 5102, "phone": "966501234568", "code": "424242", "generated": false }
    ]
  }
}
422: nothing was created
{
  "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)."
  ]
}
201 Created
{
  "data": {
    "id": 88,
    "name": "Order RC-10293",
    "status": "queued",
    "template_id": 19,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 1,
    "sent": 0,
    "delivered": 0,
    "read": 0,
    "failed": 0,
    "progress_percent": 0,
    "created_at": "2026-10-07T09:15:02+00:00",
    "completed_at": null
  }
}
Errors (11)
StatusCodeWhen
422Validation failed.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.
404Template {id} was not found for project "…".The template does not exist or belongs to another project.
422Template "…" 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…).
404WhatsApp account {id} is not connected to project "…".The account_id you sent is not one of this project's numbers.
409Project "…" has no connected WhatsApp number.No account_id was sent and the project has no WhatsApp number connected.
422Template "…" 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.
422No recipient has a valid WhatsApp number.Only with skip_invalid_phones: every number failed validation. details names each one.
402Not 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.
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Create a draft campaign

POST/api/v1/whatsapp/campaigns

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.

ParameterInTypeDescription
namerequiredbodystringLabel for the campaign, shown in the dashboard and in reports. Max 255 characters.
template_idrequiredbodyintegerAn 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_idbodyintegerThe sending number, an id from GET /accounts. Leave it out to use the project's default number (the one flagged is_default).
recipientsrequiredbodyarray<object>Between 1 and 5000 recipient objects.
recipients[].phonerequiredbodystringLocal 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_codebodystringDial 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[].namebodystringDisplay label in reports, max 255 characters. It is not inserted into the message. To greet someone by name, pass the name in variables.
recipients[].variablesbodyobjectRequired 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_variablesbodyobjectRequired 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_urlbodystring (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_urlbodystringValue 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_urlsbodyobjectOne 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_phonesbodybooleanDefaults 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.
BASH
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",
      "name": "Ahmed",
      "variables": { "1": "Ahmed", "2": "RC-10293", "3": "1450 EGP" },
      "header_media_url": "https://cdn.example.com/orders/RC-10293.jpg",
      "button_url": "RC-10293"
    }
  ]
}'
201 Created
{
  "data": {
    "id": 90,
    "name": "Order RC-10293",
    "status": "draft",
    "template_id": 19,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 1,
    "sent": 0,
    "delivered": 0,
    "read": 0,
    "failed": 0,
    "progress_percent": 0,
    "created_at": "2026-10-07T09:22:10+00:00",
    "completed_at": null
  }
}
Errors (10)
StatusCodeWhen
422Validation failed.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.
404Template {id} was not found for project "…".The template does not exist or belongs to another project.
422Template "…" 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…).
404WhatsApp account {id} is not connected to project "…".The account_id you sent is not one of this project's numbers.
409Project "…" has no connected WhatsApp number.No account_id was sent and the project has no WhatsApp number connected.
422Template "…" 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.
422No recipient has a valid WhatsApp number.Only with skip_invalid_phones: every number failed validation. details names each one.
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Send a draft

POST/api/v1/whatsapp/campaigns/{id}/send

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.

ParameterInTypeDescription
idrequiredpathintegerCampaign id: data.id from POST /send or POST /campaigns. Any campaign of this project works, including ones created in the dashboard.
skip_invalid_phonesbodybooleanDefaults 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
curl -s -X POST https://everysecond.ai/api/v1/whatsapp/campaigns/92/send \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "skip_invalid_phones": true }'
200 with skip_invalid_phones
{
  "data": {
    "id": 92,
    "name": "October promo (upload)",
    "status": "queued",
    "template_id": 12,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 139,
    "sent": 0,
    "delivered": 0,
    "read": 0,
    "failed": 0,
    "progress_percent": 0,
    "created_at": "2026-10-07T08:02:44+00:00",
    "completed_at": null,
    "skipped": [
      { "recipient_id": 6011, "phone": "12345", "error": "Too short for country code +20" }
    ]
  }
}
Response
{
  "data": {
    "id": 90,
    "name": "Order RC-10293",
    "status": "queued",
    "template_id": 19,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 1,
    "sent": 0,
    "delivered": 0,
    "read": 0,
    "failed": 0,
    "progress_percent": 0,
    "created_at": "2026-10-07T09:22:10+00:00",
    "completed_at": null
  }
}
Errors (8)
StatusCodeWhen
404Campaign {id} was not found for project "…".The campaign does not exist or belongs to another project. Campaign ids are only visible to the key of the project that owns them.
422Campaign {id} is already "queued" and can only be sent while it is a draft.The campaign has already been dispatched, cancelled or finished. Poll it instead of sending again.
422N recipient number(s) are invalid. Fix or remove them on the campaign page, or send again with "skip_invalid_phones": true to leave them out.A dashboard draft still has invalid numbers and skip_invalid_phones was not set. details names each one. Nothing is queued.
422Campaign has no valid recipients.The draft has no sendable recipient (for example every row of a dashboard draft is invalid). Nothing is removed.
402Not 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": …}. The draft is kept.
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Campaign status

GET/api/v1/whatsapp/campaigns/{id}

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.

ParameterInTypeDescription
idrequiredpathintegerCampaign id: data.id from POST /send or POST /campaigns. Any campaign of this project works, including ones created in the dashboard.
BASH
curl -s https://everysecond.ai/api/v1/whatsapp/campaigns/88 \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": {
    "id": 88,
    "name": "October promo",
    "status": "completed",
    "template_id": 12,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 250,
    "sent": 244,
    "delivered": 238,
    "read": 171,
    "failed": 6,
    "progress_percent": 100,
    "created_at": "2026-10-07T09:15:02+00:00",
    "completed_at": "2026-10-07T09:19:14+00:00"
  }
}
Errors (4)
StatusCodeWhen
404Campaign {id} was not found for project "…".The campaign does not exist or belongs to another project. Campaign ids are only visible to the key of the project that owns them.
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Recipient delivery report

GET/api/v1/whatsapp/campaigns/{id}/recipients

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.

ParameterInTypeDescription
idrequiredpathintegerCampaign id: data.id from POST /send or POST /campaigns. Any campaign of this project works, including ones created in the dashboard.
per_pagequeryintegerRows per page. Defaults to 100, maximum 500.
pagequeryintegerPage number, starting at 1. meta.last_page tells you when to stop.
BASH
curl -s "https://everysecond.ai/api/v1/whatsapp/campaigns/88/recipients?per_page=500&page=1" \
  -H "Authorization: Bearer $EVERYSECOND_KEY"
Response
{
  "data": [
    {
      "id": 5012,
      "phone": "201207256335",
      "name": "Ahmed",
      "status": "read",
      "error": null,
      "wa_message_id": "wamid.HBgMMjAxMjA3MjU2MzM1FQIAERgSQjE5RkU2QTlGQkM1NzE0RjMyAA==",
      "sent_at": "2026-10-07T09:15:03+00:00",
      "credits": 2,
      "refunded_at": null
    },
    {
      "id": 5013,
      "phone": "201001234567",
      "name": null,
      "status": "failed",
      "error": "Message Undeliverable. (code 131026)",
      "wa_message_id": "wamid.HBgMMjAxMDAxMjM0NTY3FQIAERgSNkI0QzE3RDQ4QjY2MzBFNjE0AA==",
      "sent_at": "2026-10-07T09:15:04+00:00",
      "credits": 0,
      "refunded_at": "2026-10-07T09:15:41+00:00"
    },
    {
      "id": 5014,
      "phone": "201551234567",
      "name": "Mona",
      "status": "failed",
      "error": "send_status_unknown: the request to WhatsApp may have arrived but no answer came back (cURL error 28: Operation timed out after 15001 milliseconds with 0 bytes received). It was not resent, to avoid a duplicate message; credits refunded.",
      "wa_message_id": null,
      "sent_at": null,
      "credits": 0,
      "refunded_at": "2026-10-07T09:15:20+00:00"
    }
  ],
  "meta": { "campaign_id": 88, "current_page": 1, "last_page": 3, "total": 250 }
}
Errors (4)
StatusCodeWhen
404Campaign {id} was not found for project "…".The campaign does not exist or belongs to another project. Campaign ids are only visible to the key of the project that owns them.
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Cancel a campaign

POST/api/v1/whatsapp/campaigns/{id}/cancel

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.

ParameterInTypeDescription
idrequiredpathintegerCampaign 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"
Response
{
  "data": {
    "id": 88,
    "name": "October promo",
    "status": "cancelled",
    "template_id": 12,
    "project_id": 128,
    "phone_number_id": "109876543210987",
    "total_recipients": 250,
    "sent": 120,
    "delivered": 97,
    "read": 41,
    "failed": 130,
    "progress_percent": 100,
    "created_at": "2026-10-07T09:15:02+00:00",
    "completed_at": null
  }
}
Errors (5)
StatusCodeWhen
404Campaign {id} was not found for project "…".The campaign does not exist or belongs to another project. Campaign ids are only visible to the key of the project that owns them.
422Only queued or processing campaigns can be cancelled; campaign {id} is "…".The campaign is a draft, or it has already completed, failed or been cancelled.
401Missing API key. / Invalid API key.No Authorization: Bearer or X-Api-Key header, or the key is unknown (rotated keys stop working at once).
403This 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.
429Rate 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.

Template variables, media and buttons

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.

Schema → recipient
// GET /templates/19/schema says:
//   variables: keys "1", "2", "3"
//   header: TEXT "Order {{1}}"        -> header_variables.1
//   buttons: index 1 https://shop.example/track/{{1}}  -> button_urls.1 (alias button_url)
//            index 2 https://shop.example/pay/{{1}}    -> button_urls.2
{
  "phone": "01207256335",
  "name": "Ahmed",
  "variables": { "1": "Ahmed", "2": "RC-10293", "3": "1450 EGP" },
  "header_variables": { "1": "RC-10293" },
  "button_urls": { "1": "RC-10293", "2": "https://shop.example/pay/INV-9" }
}
Authentication template: one generated code, one of your own
{
  "name": "Login code 2026-10-07 09:31",
  "template_id": 23,
  "recipients": [
    { "phone": "+966501234567" },
    { "phone": "+966501234568", "variables": { "1": "424242" } }
  ]
}

Phone number normalisation

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)

Credits and cost per message

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 }
}

Delivery statuses

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.

Replies to your messages

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.

Rate and size limits

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).

Idempotency and retries

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"
Events

Webhooks

API 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.

List webhook endpoints

GET/api/v1/webhooks

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.

BASH
curl https://everysecond.ai/api/v1/webhooks \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
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": null,
      "created_at": "2026-09-30T08:12:44+00:00"
    }
  ],
  "meta": {
    "available_events": [
      "order.created",
      "order.updated",
      "order.confirmed",
      "order.status_changed",
      "order.cancelled",
      "order.failed",
      "order.validate",
      "cart.updated",
      "cart.item_added",
      "cart.item_removed",
      "cart.converted",
      "cart.closed",
      "lead.created",
      "lead.updated",
      "lead.flagged",
      "agent.updated",
      "knowledge.added",
      "knowledge.deleted",
      "connection.requested",
      "channel.connected",
      "channel.disconnected",
      "channel.restored",
      "channel.needs_reauth",
      "channel.quality_changed",
      "template.status_changed",
      "message.received",
      "message.sent",
      "conversation.paused",
      "conversation.resumed",
      "comment.received",
      "comment.replied"
    ],
    "signature_header": "X-EverySecond-Signature"
  }
}

Register an endpoint

POST/api/v1/webhooks

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.

ParameterInTypeDescription
urlrequiredbodystringThe https endpoint on your side, up to 2048 characters.
namebodystringA label, up to 150 characters.
eventsrequiredbodyarray<string>At least one event name, e.g. ["lead.created","lead.updated"]. See GET /api/v1/webhooks for the list.
BASH
curl -X POST https://everysecond.ai/api/v1/webhooks \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "name": "CRM sync", "url": "https://crm.kosharyelnil.com/hooks/everysecond", "events": ["lead.created", "lead.updated", "lead.flagged"] }'
422
{
  "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)
StatusCodeWhen
422Validation failed.url or events missing, or events not an array — details lists the messages.
422Bad URLThat 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.
422Unknown eventsNone of those event names exist. Available: …

Update an endpoint

PATCH/api/v1/webhooks/{id}

Change the URL, name or events, or switch the endpoint off and on.

PUT on the same path behaves identically; only the keys you send change. events replaces the subscription list (unknown names dropped).

Re-enabling (is_active: true) also clears the failure streak and disabled_reason — the way back after an automatic disable.

ParameterInTypeDescription
idrequiredpathintegerThe endpoint id.
urlbodystringNew https URL, up to 2048 characters (same rules as on create).
namebodystringUp to 150 characters.
eventsbodyarray<string>At least one event name; replaces the list.
is_activebodybooleanSwitch deliveries off (false) or back on (true).
BASH
curl -X PATCH https://everysecond.ai/api/v1/webhooks/4 \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{ "is_active": true }'
Response
{
  "data": {
    "id": 4,
    "project_id": 812,
    "name": "CRM sync",
    "url": "https://crm.kosharyelnil.com/hooks/everysecond",
    "events": [
      "lead.created",
      "lead.updated",
      "lead.flagged",
      "channel.connected"
    ],
    "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"
  }
}
Errors (3)
StatusCodeWhen
404Not foundWebhook endpoint 4 was not found for project "Nour".
422Validation failed.A field breaks its rule; details lists the messages.
422Bad URL / unknown eventsSame messages as on create.

Delete an endpoint

DELETE/api/v1/webhooks/{id}

Remove the endpoint and its delivery log.

Deliveries still waiting for a retry are dropped with it.

ParameterInTypeDescription
idrequiredpathintegerThe endpoint id.
BASH
curl -X DELETE https://everysecond.ai/api/v1/webhooks/4 \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "data": {
    "deleted": true
  }
}
Errors (1)
StatusCodeWhen
404Not foundWebhook endpoint 4 was not found for project "Nour".

Rotate the secret

POST/api/v1/webhooks/{id}/rotate

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.

ParameterInTypeDescription
idrequiredpathintegerThe endpoint id.
immediatebodybooleanDrop 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)
StatusCodeWhen
404Not foundWebhook endpoint 4 was not found for project "Nour".
422Validation failed.immediate is not a boolean — details lists the message.

Send a test event

POST/api/v1/webhooks/{id}/test

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.

ParameterInTypeDescription
idrequiredpathintegerThe endpoint id.
BASH
curl -X POST https://everysecond.ai/api/v1/webhooks/4/test \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "data": {
    "id": 931,
    "endpoint_id": 4,
    "event": "webhook.test",
    "event_id": "5d1e9b3a-2c7f-4a8e-b6d0-1f3a5c7e9b24",
    "status": "pending",
    "attempts": 0,
    "response_status": null,
    "response_body": "",
    "error": null,
    "next_attempt_at": null,
    "delivered_at": null,
    "created_at": "2026-10-07T11:20:15+00:00"
  }
}
Errors (2)
StatusCodeWhen
404Not foundWebhook endpoint 4 was not found for project "Nour".
422DisabledEndpoint 4 is disabled; enable it before testing.

List deliveries

GET/api/v1/webhooks/{id}/deliveries

The delivery log of one endpoint, newest first.

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.

ParameterInTypeDescription
idrequiredpathintegerThe endpoint id.
limitqueryintegerHow many deliveries, default 50; values outside 1–200 are clamped, not rejected.
BASH
curl "https://everysecond.ai/api/v1/webhooks/4/deliveries?limit=20" \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "data": [
    {
      "id": 921,
      "endpoint_id": 4,
      "event": "lead.created",
      "event_id": "7a3c9e1f-4b6d-4c2a-8e0f-3d5b7a9c1e62",
      "status": "pending",
      "attempts": 2,
      "response_status": 503,
      "response_body": "Service Unavailable",
      "error": "Endpoint responded 503.",
      "next_attempt_at": "2026-10-07T11:19:40+00:00",
      "delivered_at": null,
      "created_at": "2026-10-07T11:13:30+00:00"
    },
    {
      "id": 918,
      "endpoint_id": 4,
      "event": "lead.updated",
      "event_id": "0c2f6a1e-8d4b-4f7a-9c3e-5b1d2e7f8a90",
      "status": "delivered",
      "attempts": 1,
      "response_status": 200,
      "response_body": "ok",
      "error": null,
      "next_attempt_at": null,
      "delivered_at": "2026-10-07T11:06:49+00:00",
      "created_at": "2026-10-07T11:06:48+00:00"
    }
  ]
}
Errors (1)
StatusCodeWhen
404Not foundWebhook endpoint 4 was not found for project "Nour".

Redeliver

POST/api/v1/webhooks/deliveries/{id}/redeliver

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.

ParameterInTypeDescription
idrequiredpathintegerThe delivery id to resend.
BASH
curl -X POST https://everysecond.ai/api/v1/webhooks/deliveries/918/redeliver \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"
Response
{
  "data": {
    "id": 932,
    "endpoint_id": 4,
    "event": "lead.updated",
    "event_id": "0c2f6a1e-8d4b-4f7a-9c3e-5b1d2e7f8a90",
    "status": "pending",
    "attempts": 0,
    "response_status": null,
    "response_body": "",
    "error": null,
    "next_attempt_at": null,
    "delivered_at": null,
    "created_at": "2026-10-07T11:20:15+00:00"
  }
}
Errors (1)
StatusCodeWhen
404Not foundDelivery 918 was not found for project "Nour".

The delivery envelope

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.

TEXT
POST /hooks/everysecond HTTP/1.1
Host: crm.kosharyelnil.com
Content-Type: application/json
User-Agent: EverySecond-Webhooks/1.0
X-EverySecond-Event: lead.flagged
X-EverySecond-Event-Id: 3e8b1d4f-6a2c-4f9e-b7d1-0c5a8e2f6b93
X-EverySecond-Delivery: 925
X-EverySecond-Signature: t=1791371208,v1=5f2b8c1e9a7d3f6b0e4c8a2d6f1b9e3c7a5d0f8b2e6c4a9d1f7b3e5c8a0d2f6b
Body (data abridged)
{
  "id": "3e8b1d4f-6a2c-4f9e-b7d1-0c5a8e2f6b93",
  "event": "lead.flagged",
  "created_at": "2026-10-07T11:06:48+00:00",
  "project": {
    "id": 812,
    "name": "Nour"
  },
  "data": {
    "lead": "{ …the lead object… }",
    "flags": [
      "Hot lead"
    ],
    "source": "ai"
  }
}

Verifying the signature

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

Retries, backoff and automatic disable

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.

Replay and backfill

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.

BASH
curl "https://everysecond.ai/api/v1/webhooks/4/deliveries?limit=200" \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"

curl -X POST https://everysecond.ai/api/v1/webhooks/deliveries/921/redeliver \
  -H "Authorization: Bearer $EVERYSECOND_KEY" \
  -H "Accept: application/json"

The test event

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.

JSON
{
  "id": "5d1e9b3a-2c7f-4a8e-b6d0-1f3a5c7e9b24",
  "event": "webhook.test",
  "created_at": "2026-10-07T11:20:15+00:00",
  "project": { "id": 812, "name": "Nour" },
  "data": {
    "message": "Test event from EverySecond. Your endpoint is reachable and the signature above is real.",
    "sample_order": {
      "id": 0,
      "project_id": 812,
      "branch": { "id": 0, "name": "Sample branch" },
      "status": "pending",
      "created_by": "ai",
      "customer": { "name": "Sample Customer", "phone": "+201234567890", "sender_id": "+201234567890", "lead_id": null },
      "fulfillment_type": "delivery",
      "address": "1 Sample St",
      "delivery_zone": "Sample zone",
      "notes": null,
      "items": [
        { "menu_item_id": 0, "offer_id": null, "components": null, "name": "Sample item", "unit_price": 100.0, "qty": 2, "line_total": 200.0 }
      ],
      "items_total": 200.0,
      "delivery_fee": 20.0,
      "total": 220.0,
      "currency": "EGP",
      "customer_confirmed_at": "2026-10-07T11:20:15+00:00",
      "staff_confirmed_at": null,
      "created_at": "2026-10-07T11:20:15+00:00",
      "updated_at": "2026-10-07T11:20:15+00:00"
    }
  }
}

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": {
    "id": 90533,
    "created_at": "2026-10-07T12:40:03+00:00",
    "updated_at": "2026-10-07T12:40:03+00:00",
    "contact": {
      "name": "Ahmed Samir",
      "first_name": "Ahmed",
      "last_name": "Samir",
      "phone": null,
      "phone_e164": null,
      "email": null,
      "language": null,
      "external_id": "7425196083317462"
    },
    "attribution": {
      "source": "everysecond",
      "channel": "facebook",
      "channel_code": "fb",
      "agent_id": 812,
      "agent_name": "Nour"
    },
    "crm": {
      "status": "new",
      "assigned_to": null,
      "source": "chat",
      "next_follow_up": null
    },
    "qualification": {
      "score": 0,
      "score_reason": null,
      "summary": null,
      "inquiry": null,
      "flags": []
    },
    "links": {
      "conversation": "https://everysecond.ai/api/v1/leads/90533/conversation",
      "dashboard": "https://everysecond.ai/leads/90533"
    }
  }
}
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": {
    "id": 90412,
    "created_at": "2026-10-07T11:02:11+00:00",
    "updated_at": "2026-10-07T11:06:48+00:00",
    "contact": {
      "name": "Mona Adel",
      "first_name": "Mona",
      "last_name": "Adel",
      "phone": "01001234567",
      "phone_e164": "+201001234567",
      "email": null,
      "language": "ar",
      "external_id": "201001234567"
    },
    "attribution": {
      "source": "everysecond",
      "channel": "whatsapp",
      "channel_code": "wp",
      "agent_id": 812,
      "agent_name": "Nour"
    },
    "crm": {
      "status": "new",
      "assigned_to": null,
      "source": "chat",
      "next_follow_up": null
    },
    "qualification": {
      "score": 64,
      "score_reason": "Asked for prices and gave a delivery address",
      "summary": "Wants two family koshary boxes delivered to Maadi tonight.",
      "inquiry": "Delivery to Maadi",
      "flags": [
        "Hot lead"
      ]
    },
    "links": {
      "conversation": "https://everysecond.ai/api/v1/leads/90412/conversation",
      "dashboard": "https://everysecond.ai/leads/90412"
    }
  },
  "changes": [
    "qualification.score",
    "qualification.score_reason",
    "qualification.summary"
  ]
}
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
{
  "lead": {
    "id": 90412,
    "created_at": "2026-10-07T11:02:11+00:00",
    "updated_at": "2026-10-07T11:06:48+00:00",
    "contact": {
      "name": "Mona Adel",
      "first_name": "Mona",
      "last_name": "Adel",
      "phone": "01001234567",
      "phone_e164": "+201001234567",
      "email": null,
      "language": "ar",
      "external_id": "201001234567"
    },
    "attribution": {
      "source": "everysecond",
      "channel": "whatsapp",
      "channel_code": "wp",
      "agent_id": 812,
      "agent_name": "Nour"
    },
    "crm": {
      "status": "new",
      "assigned_to": null,
      "source": "chat",
      "next_follow_up": null
    },
    "qualification": {
      "score": 64,
      "score_reason": "Asked for prices and gave a delivery address",
      "summary": "Wants two family koshary boxes delivered to Maadi tonight.",
      "inquiry": "Delivery to Maadi",
      "flags": [
        "Hot lead"
      ]
    },
    "links": {
      "conversation": "https://everysecond.ai/api/v1/leads/90412/conversation",
      "dashboard": "https://everysecond.ai/leads/90412"
    }
  },
  "flags": [
    "Hot lead"
  ],
  "source": "ai"
}

Agent

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
{
  "document": {
    "id": "pdf_6703f2c9d1e8a4.87654321",
    "type": "file",
    "name": "menu-october.pdf"
  },
  "source": "dashboard"
}
knowledge.deletedA document was removed — through DELETE /api/v1/agent/knowledge/{id} (source: "api") or in the dashboard (source: "dashboard").
data
{
  "document": {
    "id": "pdf_6703f2c9d1e8a4.87654321"
  },
  "source": "api"
}

Channels

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
{
  "connection": {
    "id": 12,
    "channel": "whatsapp",
    "status": "pending",
    "note": "Please connect the branch number 01112345678.",
    "connect_url": "https://everysecond.ai/whatsapp/connect/812",
    "requires_login": true,
    "requested_at": "2026-10-07T11:45:30+00:00",
    "completed_at": null
  },
  "source": "api"
}
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
{
  "template": {
    "id": 33,
    "name": "order_ready",
    "language": "ar",
    "category": "UTILITY",
    "meta_template_id": "1198734561230987"
  },
  "status": "rejected",
  "previous_status": "pending",
  "reason": "INVALID_FORMAT",
  "number": {
    "phone_number_id": "109876543210987",
    "display_phone_number": "+20 100 000 0001",
    "waba_id": "102938475610293"
  }
}

Conversations

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": {
    "id": 98231,
    "text": "بكام البوكس العائلي؟",
    "media": [],
    "created_at": "2026-10-08T10:12:03+00:00"
  },
  "conversation": {
    "channel": "whatsapp",
    "channel_id": "109876543210987",
    "customer": {
      "id": "201001234567",
      "name": "Karim Adel"
    },
    "lead_id": 512
  },
  "referral": {
    "source": "ad",
    "ad_id": "120211998877",
    "post_id": null,
    "headline": "Family box — 20% off",
    "body": null,
    "source_url": "https://fb.me/2abcXyz",
    "ref": null,
    "ctwa_clid": "ARAkLmNoPq"
  }
}
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
{
  "message": {
    "id": 98232,
    "text": "البوكس العائلي بـ 450 جنيه ويكفي 4 أفراد. تحب أسجلك طلب؟",
    "media": [],
    "created_at": "2026-10-08T10:12:09+00:00"
  },
  "conversation": {
    "channel": "whatsapp",
    "channel_id": "109876543210987",
    "customer": {
      "id": "201001234567",
      "name": "Karim Adel"
    },
    "lead_id": 512
  },
  "author": "agent"
}
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
{
  "conversation": {
    "channel": "facebook",
    "channel_id": "104857392011873",
    "customer": {
      "id": "6723451198765432",
      "name": "Mona Adel"
    },
    "lead_id": 488
  },
  "resume": {
    "reason": "manual",
    "by": "Mona",
    "paused_reason": "inbox_reply"
  }
}

Comments

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": {
    "id": 7731,
    "meta_comment_id": "104857392011873_1122334455",
    "text": "بكام ده؟",
    "post_id": "104857392011873_998877665544",
    "created_at": "2026-10-08T09:40:12+00:00"
  },
  "channel": "facebook",
  "channel_id": "104857392011873",
  "commenter": {
    "id": "6723451198765432",
    "name": "Mona Adel"
  }
}
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
{
  "comment": {
    "id": 7731,
    "meta_comment_id": "104857392011873_1122334455",
    "text": "بكام ده؟",
    "post_id": "104857392011873_998877665544",
    "created_at": "2026-10-08T09:40:12+00:00"
  },
  "reply": {
    "id": 15502,
    "kind": "private",
    "text": "أهلاً منى! السعر 450 جنيه، بعتنالك التفاصيل.",
    "author": "agent",
    "source": "auto",
    "meta_reply_id": null,
    "created_at": "2026-10-08T09:40:15+00:00"
  },
  "channel": "facebook",
  "channel_id": "104857392011873",
  "commenter": {
    "id": "6723451198765432",
    "name": "Mona Adel"
  }
}

Reservations

reservation.createdA booking was made — by the agent in a chat, in the dashboard, or through the API.
data
{ "reservation": {
    "id": 482,
    "external_ref": "HIS-88421",
    "branch_id": 3,
    "branch_name": "Nasr City",
    "resource_id": 12,
    "resource": {
      "id": 12, "kind": "doctor", "name": "Dr. Mona Adel",
      "path": ["Cardiology", "Clinic 2", "Dr. Mona Adel"],
      "meta": { "title": "Consultant", "fee": 500 }
    },
    "check_in": null, "check_out": null, "nights": null,
    "lead_id": 9182, "bot_id": null, "sender_id": null,
    "customer_name": "Ahmed Samir",
    "customer_phone": "01012345678",
    "start_at": "2026-10-12T10:30:00+03:00",
    "end_at": "2026-10-12T11:00:00+03:00",
    "all_day": false,
    "title": "Dr. Mona Adel",
    "notes": "Chest pain follow-up",
    "status": "confirmed",
    "created_by": "api",
    "source": "api",
    "is_ai": false,
    "created_at": "2026-10-08T09:14:02+00:00",
    "updated_at": "2026-10-08T09:14:02+00:00"
  },
  "change": "created", "changed_by": "api" }
reservation.updatedA booking moved (time, doctor, room or stay dates — change: rescheduled) or its details changed (change: edited).
data
{ "reservation": { "id": 482, "status": "rescheduled", "start_at": "2026-10-12T11:00:00+03:00", "…": "…" },
  "change": "rescheduled", "changed_by": "dashboard",
  "previous": { "start_at": "2026-10-12T10:30:00+03:00", "end_at": "2026-10-12T11:00:00+03:00", "status": "confirmed" } }
reservation.confirmedA booking was confirmed (staff, or your system via /confirm).
data
{ "reservation": { "id": 517, "status": "confirmed", "…": "…" }, "change": "confirmed", "changed_by": "dashboard",
  "previous": { "status": "pending" } }
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": { "id": 482, "status": "cancelled", "…": "…" }, "change": "cancelled", "changed_by": "ai",
  "previous": { "status": "confirmed" } }
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": {
    "id": 640, "external_ref": "TRV-1187", "branch_id": 7, "branch_name": "Downtown office",
    "resource_id": 31,
    "resource": { "id": 31, "kind": "program", "name": "Siwa Oasis", "path": ["Siwa Oasis"], "meta": { "price": 4500, "duration": "3 days / 2 nights", "meeting_point": "Tahrir Square" } },
    "check_in": null, "check_out": null, "nights": null,
    "departure_id": 502,
    "departure": { "id": 502, "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00", "capacity": 20, "price": 3900.0 },
    "party_size": 3, "waitlist_position": 2, "promoted_at": null, "promotion_notice": null,
    "lead_id": null, "bot_id": null, "sender_id": null,
    "customer_name": "Hany Fouad", "customer_phone": "01223344556",
    "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00", "all_day": false,
    "title": "Siwa Oasis", "notes": "One vegetarian", "status": "waitlisted", "created_by": "api", "source": "api", "is_ai": false,
    "created_at": "2026-10-08T11:20:41+00:00", "updated_at": "2026-10-08T11:20:41+00:00"
  },
  "change": "waitlisted", "changed_by": "api" }
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": { "id": 640, "status": "confirmed", "departure_id": 502, "party_size": 3, "waitlist_position": null, "promoted_at": "2026-10-09T08:02:10+00:00", "…": "…" },
  "change": "promoted", "changed_by": "system",
  "previous": { "status": "waitlisted" },
  "trigger": { "reason": "cancellation", "by": "ai" } }

Reservation setup

reservation_resource.createdA specialty, clinic, doctor, room or trip program was added (resource.kind).
data
{ "resource": { "id": 31, "project_id": 2, "branch_id": 7, "parent_id": null, "kind": "program", "name": "Siwa Oasis",
    "meta": { "price": 4500, "duration": "3 days / 2 nights" }, "schedule": null, "slot_minutes": null, "active": true, "sort_order": 1,
    "photos": [], "path": ["Siwa Oasis"] },
  "change": "created", "changed_by": "dashboard" }
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
{ "resource": { "id": 21, "kind": "room", "name": "101", "active": true, "photos": [{ "id": 90, "url": "https://everysecond.ai/storage/reservation-photos/2/a1b2….jpg", "width": 1600, "height": 1200, "mime": "image/jpeg", "sort_order": 0 }], "…": "…" },
  "change": "photos", "changed_by": "dashboard", "photos_change": "added" }
reservation_resource.deletedOne was deleted (only possible when nothing was ever booked on it); resource is its last state.
data
{ "resource": { "id": 12, "kind": "doctor", "name": "Dr. Mona Adel", "path": ["Cardiology", "Clinic 2", "Dr. Mona Adel"], "…": "…" },
  "change": "deleted", "changed_by": "dashboard" }
departure.createdA tour departure was scheduled — one event per departure; a repeat (weekly until a date…) shares one departure.series.
data
{ "departure": {
    "id": 502, "program_id": 31, "program": { "id": 31, "name": "Siwa Oasis" }, "branch_id": 7,
    "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00",
    "capacity": 20, "booked": 0, "seats_left": 20, "waitlist": 0, "waitlist_travelers": 0,
    "price": 3900.0, "price_override": 3900.0, "active": true, "is_full": false, "notes": null,
    "series": "8f0c2d1e-5b7a-4c1e-9a43-0d6f2b9c7e11"
  },
  "change": "created", "changed_by": "dashboard" }
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": { "id": 502, "capacity": 24, "booked": 23, "seats_left": 1, "waitlist": 1, "…": "…" },
  "change": "edited", "changed_by": "dashboard", "changes": ["capacity"], "previous": { "capacity": 20 } }
departure.deletedA departure without any booking was deleted; departure is its last state.
data
{ "departure": { "id": 503, "program_id": 31, "start_at": "2026-10-29T07:00:00+03:00", "…": "…" }, "change": "deleted", "changed_by": "dashboard" }
departure.fullThe last seat of a departure was taken (seats_left 0) — new bookings now go on its waiting list.
data
{ "departure": {
    "id": 502, "program_id": 31, "program": { "id": 31, "name": "Siwa Oasis" }, "branch_id": 7,
    "start_at": "2026-10-22T07:00:00+03:00", "end_at": "2026-10-24T20:00:00+03:00",
    "capacity": 20, "booked": 20, "seats_left": 0, "waitlist": 2, "waitlist_travelers": 5,
    "price": 3900.0, "price_override": 3900.0, "active": true, "is_full": true, "notes": null,
    "series": "8f0c2d1e-5b7a-4c1e-9a43-0d6f2b9c7e11"
  },
  "change": "full", "changed_by": "api" }
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
{ "departure": { "id": 502, "capacity": 20, "booked": 19, "seats_left": 1, "is_full": false, "…": "…" }, "change": "reopened", "changed_by": "ai" }
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
{ "project_id": 2, "type": "tourism", "change": "type", "changed_by": "dashboard", "previous": { "type": "general" } }

Orders

order.createdAn order is saved — by the agent after the customer's explicit yes in chat, or by staff on the dashboard.
data
{
  "order": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "pending",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": null,
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:20:03+00:00"
  }
}
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": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "out_for_delivery",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": "https://track.example-burgers.com/SO-1042",
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:51:09+00:00"
  },
  "changed": [
    "tracking_url",
    "merchant_ref"
  ]
}
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": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "confirmed",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:22:41+00:00"
  },
  "confirmed_by": "api"
}
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": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "out_for_delivery",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": "https://track.example-burgers.com/SO-1042",
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": "2026-10-07T14:22:41+00:00",
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:51:09+00:00"
  },
  "previous_status": "confirmed",
  "status": "out_for_delivery"
}
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": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "cancelled",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": null,
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:51:09+00:00"
  },
  "cancelled_by": "ai"
}
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
{
  "reason": "out_of_stock",
  "context": {
    "menu_item_id": 311,
    "name": "Classic Burger",
    "wanted": 3,
    "left": 1
  },
  "attempt": {
    "branch_id": 7,
    "sender_id": "201012345678",
    "lead_id": 88,
    "customer_name": "Ahmed Samir",
    "customer_phone": "01012345678",
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "items": [
      {
        "menu_item_id": 311,
        "qty": 3
      },
      {
        "menu_item_id": 312,
        "qty": 2
      }
    ]
  }
}

Order validation (your system answers)

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
{
  "stage": "place",
  "branch_id": 7,
  "currency": "EGP",
  "fulfillment": {
    "type": "delivery",
    "zone": "Maadi",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    }
  },
  "customer": {
    "name": "Ahmed Samir",
    "phone": "01012345678",
    "phone_e164": "+201012345678",
    "language": "ar",
    "sender_id": "201012345678",
    "lead_id": 88
  },
  "channel": "whatsapp",
  "items": [
    {
      "external_id": "BRG-CLASSIC",
      "menu_item_id": 311,
      "name": "Classic Burger",
      "options": {
        "size": "Large"
      },
      "qty": 2,
      "unit_price": 120.0
    },
    {
      "external_id": "DRK-COLA",
      "menu_item_id": 312,
      "name": "Cola 330ml",
      "qty": 2,
      "unit_price": 25.0
    }
  ],
  "items_total": 290.0,
  "delivery_fee": 20.0
}

Carts

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
{
  "source": "agent",
  "cart": {
    "id": 77,
    "project_id": 130,
    "branch_id": 7,
    "lead_id": 88,
    "sender_id": "201012345678",
    "customer_name": "Ahmed Samir",
    "status": "active",
    "items": [
      {
        "line_id": "item:311",
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "line_id": "item:312",
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "name": "Cola 330ml",
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "agent",
    "closed_at": null,
    "closed_reason": null,
    "last_activity_at": "2026-10-07T14:12:40+00:00",
    "created_at": "2026-10-07T14:05:11+00:00"
  },
  "added": [
    {
      "line_id": "item:312",
      "menu_item_id": 312,
      "external_id": "DRK-COLA",
      "offer_id": null,
      "name": "Cola 330ml",
      "unit_price": 25.0,
      "qty": 2,
      "line_total": 50.0
    }
  ],
  "removed": []
}
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
{
  "source": "agent",
  "cart": {
    "id": 77,
    "project_id": 130,
    "branch_id": 7,
    "lead_id": 88,
    "sender_id": "201012345678",
    "customer_name": "Ahmed Samir",
    "status": "active",
    "items": [
      {
        "line_id": "item:311",
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "line_id": "item:312",
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "name": "Cola 330ml",
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "agent",
    "closed_at": null,
    "closed_reason": null,
    "last_activity_at": "2026-10-07T14:12:40+00:00",
    "created_at": "2026-10-07T14:05:11+00:00"
  },
  "items": [
    {
      "line_id": "item:312",
      "menu_item_id": 312,
      "external_id": "DRK-COLA",
      "offer_id": null,
      "name": "Cola 330ml",
      "unit_price": 25.0,
      "qty": 2,
      "line_total": 50.0
    }
  ]
}
cart.item_removedItems were taken out of the basket or a quantity went down; items holds what left, with the quantity that moved.
data
{
  "source": "agent",
  "cart": {
    "id": 77,
    "project_id": 130,
    "branch_id": 7,
    "lead_id": 88,
    "sender_id": "201012345678",
    "customer_name": "Ahmed Samir",
    "status": "active",
    "items": [
      {
        "line_id": "item:311",
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 1,
        "line_total": 120.0
      },
      {
        "line_id": "item:312",
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "name": "Cola 330ml",
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 170.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "agent",
    "closed_at": null,
    "closed_reason": null,
    "last_activity_at": "2026-10-07T14:15:02+00:00",
    "created_at": "2026-10-07T14:05:11+00:00"
  },
  "items": [
    {
      "line_id": "item:311",
      "menu_item_id": 311,
      "external_id": "BRG-CLASSIC",
      "offer_id": null,
      "name": "Classic Burger",
      "unit_price": 120.0,
      "qty": 1,
      "line_total": 120.0
    }
  ]
}
cart.convertedThe basket became a real order (sent together with order.created).
data
{
  "source": "agent",
  "cart": {
    "id": 77,
    "project_id": 130,
    "branch_id": 7,
    "lead_id": 88,
    "sender_id": "201012345678",
    "customer_name": "Ahmed Samir",
    "status": "converted",
    "items": [
      {
        "line_id": "item:311",
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "line_id": "item:312",
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "name": "Cola 330ml",
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "merchant_quote": null,
    "converted_order_id": 512,
    "changed_by": "agent",
    "closed_at": null,
    "closed_reason": null,
    "last_activity_at": "2026-10-07T14:20:03+00:00",
    "created_at": "2026-10-07T14:05:11+00:00"
  },
  "order": {
    "id": 512,
    "project_id": 130,
    "branch": {
      "id": 7,
      "name": "Downtown"
    },
    "status": "pending",
    "merchant_ref": "SO-1042",
    "payment_url": "https://pay.example-burgers.com/SO-1042",
    "tracking_url": null,
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "ai",
    "channel": "whatsapp",
    "source": "chat",
    "store_order_id": null,
    "chat_id": null,
    "conversation_url": "https://everysecond.ai/leads/88",
    "customer": {
      "name": "Ahmed Samir",
      "phone": "01012345678",
      "phone_e164": "+201012345678",
      "language": "ar",
      "sender_id": "201012345678",
      "lead_id": 88
    },
    "fulfillment_type": "delivery",
    "address": "12 Nile St, building 4, flat 7, Maadi",
    "address_details": {
      "area": "Maadi",
      "street": "12 Nile St",
      "building": "4",
      "unit": "7"
    },
    "delivery_zone": "Maadi",
    "notes": "No onions, please",
    "items": [
      {
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "components": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "components": null,
        "name": "Cola 330ml",
        "options": null,
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "delivery_fee": 20.0,
    "total": 310.0,
    "currency": "EGP",
    "customer_confirmed_at": "2026-10-07T14:20:03+00:00",
    "staff_confirmed_at": null,
    "created_at": "2026-10-07T14:20:03+00:00",
    "updated_at": "2026-10-07T14:20:03+00:00"
  }
}
cart.closedYour system closed the basket without an order through DELETE /api/v1/chats/{id}/cart (for example, it was paid on your website).
data
{
  "source": "api",
  "cart": {
    "id": 77,
    "project_id": 130,
    "branch_id": 7,
    "lead_id": 88,
    "sender_id": "201012345678",
    "customer_name": "Ahmed Samir",
    "status": "closed",
    "items": [
      {
        "line_id": "item:311",
        "menu_item_id": 311,
        "external_id": "BRG-CLASSIC",
        "offer_id": null,
        "name": "Classic Burger",
        "options": {
          "size": "Large"
        },
        "unit_price": 120.0,
        "qty": 2,
        "line_total": 240.0
      },
      {
        "line_id": "item:312",
        "menu_item_id": 312,
        "external_id": "DRK-COLA",
        "offer_id": null,
        "name": "Cola 330ml",
        "unit_price": 25.0,
        "qty": 2,
        "line_total": 50.0
      }
    ],
    "items_total": 290.0,
    "merchant_quote": null,
    "converted_order_id": null,
    "changed_by": "api",
    "closed_at": "2026-10-07T14:30:15+00:00",
    "closed_reason": "paid_on_website",
    "last_activity_at": "2026-10-07T14:30:15+00:00",
    "created_at": "2026-10-07T14:05:11+00:00"
  }
}

Store & catalog

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
{
  "source": "shopify",
  "product": {
    "menu_item_id": 9021,
    "branch_id": 31,
    "external_id": "shopify:44012345",
    "store_source": "shopify",
    "store_product_id": "8801234",
    "store_variant_id": "44012345",
    "name": {
      "en": "Linen Shirt - M / White"
    },
    "category": "Shirts",
    "price": 199.0,
    "offer_price": 149.0,
    "effective_price": 149.0,
    "currency": "SAR",
    "available": true,
    "stock_qty": 7,
    "in_stock": true,
    "image_url": "https://everysecond.ai/storage/menu-items/synced/130/9021.jpg",
    "product_url": "https://nilewear.example/products/linen-shirt",
    "buy_url": "https://nilewear.example/cart/44012345:1",
    "option_group": "Linen Shirt",
    "options": {
      "Size": "M",
      "Color": "White"
    },
    "orderable_in_chat": true,
    "updated_at": "2026-10-08T09:12:44+00:00"
  }
}
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
{
  "source": "shopify",
  "changes": [
    "offer_price",
    "stock_qty"
  ],
  "product": {
    "menu_item_id": 9021,
    "branch_id": 31,
    "external_id": "shopify:44012345",
    "store_source": "shopify",
    "store_product_id": "8801234",
    "store_variant_id": "44012345",
    "name": {
      "en": "Linen Shirt - M / White"
    },
    "category": "Shirts",
    "price": 199.0,
    "offer_price": 149.0,
    "effective_price": 149.0,
    "currency": "SAR",
    "available": true,
    "stock_qty": 7,
    "in_stock": true,
    "image_url": "https://everysecond.ai/storage/menu-items/synced/130/9021.jpg",
    "product_url": "https://nilewear.example/products/linen-shirt",
    "buy_url": "https://nilewear.example/cart/44012345:1",
    "option_group": "Linen Shirt",
    "options": {
      "Size": "M",
      "Color": "White"
    },
    "orderable_in_chat": true,
    "updated_at": "2026-10-08T09:12:44+00:00"
  }
}
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
{
  "source": "feed",
  "product": {
    "menu_item_id": 9021,
    "branch_id": 31,
    "external_id": "feed:SKU-1",
    "store_source": "feed",
    "store_product_id": "8801234",
    "store_variant_id": "44012345",
    "name": {
      "en": "Linen Shirt - M / White"
    },
    "category": "Shirts",
    "price": 199.0,
    "offer_price": 149.0,
    "effective_price": 149.0,
    "currency": "SAR",
    "available": true,
    "stock_qty": 7,
    "in_stock": true,
    "image_url": "https://everysecond.ai/storage/menu-items/synced/130/9021.jpg",
    "product_url": "https://nilewear.example/products/linen-shirt",
    "buy_url": null,
    "option_group": "Linen Shirt",
    "options": {
      "Size": "M",
      "Color": "White"
    },
    "orderable_in_chat": true,
    "updated_at": "2026-10-08T09:12:44+00:00"
  }
}
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": {
    "id": 14,
    "project_id": 130,
    "platform": "shopify",
    "status": "connected",
    "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": 0,
    "connected_at": "2026-10-08T09:10:02+00:00",
    "last_sync_at": null,
    "last_sync_status": null,
    "last_sync_counts": null,
    "last_error": null,
    "next_sync_at": null
  }
}
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": {
    "id": 14,
    "project_id": 130,
    "platform": "shopify",
    "status": "disconnected",
    "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": false,
      "refresh_token": false,
      "api_key": false,
      "webhook_secret": false
    },
    "token_expires_at": "2026-10-09T09:10:02+00:00",
    "webhooks": [],
    "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": "ok",
    "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": null,
    "next_sync_at": null
  },
  "reason": "uninstalled"
}
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
{
  "store": {
    "id": 14,
    "project_id": 130,
    "platform": "salla",
    "status": "needs_reauth",
    "shop_domain": "nile.salla.sa",
    "store_url": "https://nile.salla.sa",
    "store_name": "متجر النيل",
    "external_store_id": "1234509876",
    "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": "ok",
    "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": "Salla refused to renew the access (HTTP 400) — connect the store again.",
    "next_sync_at": null
  },
  "error": "Salla refused to renew the access (HTTP 400) — connect the store again."
}
catalog.sync_completedA catalogue sync finished — trigger is connect, manual (Sync now), schedule or upload; counts says what changed.
data
{
  "store": {
    "id": 14,
    "project_id": 130,
    "platform": "shopify",
    "status": "connected",
    "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": "ok",
    "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": null,
    "next_sync_at": "2026-10-09T09:12:44+00:00"
  },
  "trigger": "schedule",
  "counts": {
    "created": 4,
    "updated": 27,
    "unchanged": 1901,
    "deleted": 2,
    "skipped": 1,
    "total": 1935,
    "errors": []
  },
  "duration_ms": 48210
}
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
{
  "store": {
    "id": 14,
    "project_id": 130,
    "platform": "shopify",
    "status": "connected",
    "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": "ok",
    "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": null,
    "next_sync_at": "2026-10-09T09:12:44+00:00"
  },
  "store_order_id": "820982911946154508",
  "order": {
    "id": 640,
    "project_id": 130,
    "branch": {
      "id": 31,
      "name": "Online store"
    },
    "status": "confirmed",
    "merchant_ref": "#1001",
    "payment_url": null,
    "tracking_url": "https://nilewear.example/orders/5f2c/authenticate",
    "requires_staff_approval": false,
    "awaiting_approval": false,
    "created_by": "store",
    "channel": "shopify",
    "source": "shopify",
    "store_order_id": "820982911946154508",
    "chat_id": null,
    "conversation_url": null,
    "customer": {
      "name": "Sara Ali",
      "phone": "+966501234567",
      "phone_e164": "+966501234567",
      "language": null,
      "sender_id": null,
      "lead_id": null
    },
    "fulfillment_type": "delivery",
    "address": "12 Olaya St, Riyadh, Saudi Arabia",
    "address_details": {
      "area": "Riyadh",
      "street": "12 Olaya St"
    },
    "delivery_zone": null,
    "notes": null,
    "items": [
      {
        "menu_item_id": 9021,
        "external_id": "shopify:44012345",
        "offer_id": null,
        "components": null,
        "name": "Linen Shirt - M / White",
        "options": {
          "Size": "M",
          "Color": "White"
        },
        "unit_price": 149.0,
        "qty": 2,
        "line_total": 298.0
      }
    ],
    "items_total": 298.0,
    "delivery_fee": 25.0,
    "total": 323.0,
    "currency": "SAR",
    "customer_confirmed_at": "2026-10-08T09:12:44+00:00",
    "staff_confirmed_at": "2026-10-08T09:12:44+00:00",
    "created_at": "2026-10-08T09:12:44+00:00",
    "updated_at": "2026-10-08T09:12:44+00:00"
  }
}