Skip to content

Paid add-on. Service is licensed separately from the open-source packages. See pricing

Chat widget

Embed the support chat widget on any site, and use the chat JSON API that powers it.

The package includes widget.js, a dependency-free script that adds a chat launcher to any web page. A visitor enters their name, email, and question, which starts a ticket with source Chat. Agents answer with the normal ticket actions, and the widget polls for new messages.

Embedding the widget

The package serves the script itself at GET /widget.js in the API route group (route name odden.service.widget, so /api/service/widget.js with the default prefix), cached for an hour. You can load it straight from there, or publish a copy into your public directory (or asset pipeline) with php artisan vendor:publish --tag=odden-service-widget, which writes public/js/odden-chat-widget.js.

Add it to the pages where the launcher should appear. Set window.ODDEN_CHAT_API_URL to the full base URL of the chat API, including any configured prefix, before the script loads:

html
<script>
    window.ODDEN_CHAT_API_URL = 'https://crm.example.com/api/service';
</script>
<script src="https://crm.example.com/api/service/widget.js" async></script>

Without ODDEN_CHAT_API_URL, the widget calls /api/service on the page's own origin. Re-publish the file when you update the package if you use a published copy. Copies taken from earlier versions inserted the sender name into the page as HTML, so re-publish the script if yours predates this fix.

The widget:

  • adds a launcher button in the bottom-right corner and a chat window, with inline styles;
  • shows a start form with name, email, optional company, and message;
  • stores the session token in localStorage under odden_support_chat_token, so a returning visitor sees the same conversation;
  • polls for messages every 4 seconds while the window is open;
  • renders each message's sender_name, body, and created_at as text (with textContent), so HTML in a visitor's name or message is shown as typed, never run;
  • loads only once per page (window.OddenChatWidgetLoaded).

Cross-origin use

When the widget runs on a different domain from your Laravel app, the browser sends CORS preflight requests. Laravel's HandleCors middleware answers them for the paths in config/cors.php, which by default include api/* and allow all origins, so the default api/service prefix works. If you change ODDEN_SERVICE_API_PREFIX to something outside api/, or restrict allowed_origins, update config/cors.php to match.

The chat endpoints are exempt from CSRF verification, so no token is needed. They still run the web middleware group by default (see Configuration reference), which starts a session for each request.

What starting a chat does

POST /chat/start:

  1. Finds or creates a Core Contact by email. The lookup ignores case and surrounding whitespace, so Dana@Example.com finds the contact dana@example.com (and older contacts saved with mixed case). New contacts are stored with the email lowercased and trimmed, the name split at the first space, and lifecycle_stage customer.
  2. If company is given, the name is only stored on the ticket (its unverified_company_name property) with an internal note for an agent to confirm. It is never matched to an existing Core Company or used to create one: a visitor typing a key account's name must not appear under that company in the CRM. An agent links the company in the ticket's edit form once they know who they are talking to.
  3. Creates the ticket with CreateTicketAction: subject Live Chat inquiry from {full name}, source Chat, the default priority (odden-service.defaults.priority), the contact's first company if an existing contact has one, and the message as description, which becomes the first Customer message. Like portal and email tickets, it starts as New, gets the default SLA policy, is routed (a rule with "source": "chat" matches only chat tickets, and a matching rule moves it to Open), and a task is logged on the contact's timeline.
  4. Queues TicketCreatedNotification to the contact, only if odden-service.chat.confirmation_email is true (it is false by default, see below).
  5. Adds a System welcome message: "Hi {first name}! Thanks for reaching out to support. A member of our team has received your message and will reply here momentarily." (with a wave emoji after the name).

Confirmation email

By default a chat ticket doesn't send the confirmation email. POST /chat/start is public, exempt from CSRF verification, and never verifies the email address, so sending it would let anyone, from any site, make your app email any address. The visitor first hears from you by email when an agent replies.

To send it anyway, set ODDEN_SERVICE_CHAT_CONFIRMATION_EMAIL=true (odden-service.chat.confirmation_email). The email is then the visitor's durable record of the conversation: it carries the portal link and a ticket Message-ID, so they can come back to it after clearing their browser, or simply reply by email (see Email to ticket). The subject and contact name in it are escaped, so Markdown or HTML typed into the chat form is shown as typed, not turned into links. Routing and the timeline task happen either way.

The token returned is the ticket's portal_token, so the same conversation is also available at the support portal URL.

Replying from your agent UI

Reply with ReplyTicketAction. The reply shows in the widget on its next poll. Because the chat contact has an email address, a public agent reply also emails them TicketRepliedNotification. Internal notes never appear in the widget.

API reference

All three endpoints are in the api route group, under /api/service by default. The group uses Laravel's stateless api middleware, so polling never starts a session or sets a cookie.

Method URI Route name Rate limit CSRF
POST /api/service/chat/start odden.service.chat.start odden-public Exempt
POST /api/service/chat/{token}/message odden.service.chat.message odden-public Exempt
GET /api/service/chat/{token}/messages odden.service.chat.messages None n/a

If you build your own client, send Accept: application/json so validation errors come back as 422 JSON instead of a redirect.

Start a chat

bash
curl -X POST https://crm.example.com/api/service/chat/start \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"name": "Dana Scully", "email": "dana@example.com", "company": "Acme", "message": "Is there an API rate limit?"}'
Field Rules
name required, string, max 255
email required, email, max 255
message required, string, max 5,000 characters
company optional, string, max 255

Response, 201:

json
{
    "success": true,
    "token": "3Ekp9wqpTbwiMh55MGZdv1aOUu3QOWG9kulLvRJf",
    "ticket_number": "TICK-2026-7WBPJ",
    "messages": [
        {
            "id": 1,
            "sender_type": "customer",
            "sender_name": "Dana Scully",
            "body": "Is there an API rate limit?",
            "is_customer": true,
            "created_at": "0 seconds ago"
        },
        {
            "id": 2,
            "sender_type": "system",
            "sender_name": "System Automation",
            "body": "Hi Dana! ๐Ÿ‘‹ Thanks for reaching out to support. A member of our team has received your message and will reply here momentarily.",
            "is_customer": false,
            "created_at": "0 seconds ago"
        }
    ]
}

Each message has id, sender_type (customer, agent, or system), sender_name (see TicketMessage::senderName()), body, is_customer, and created_at as a relative time string such as "5 minutes ago". Internal notes are never included.

Send a message

bash
curl -X POST https://crm.example.com/api/service/chat/3Ekp9wqpTbwiMh55MGZdv1aOUu3QOWG9kulLvRJf/message \
  -H "Accept: application/json" \
  -H "Content-Type: application/json" \
  -d '{"message": "Thanks, that helps."}'

message is required (at most 5,000 characters). The message is added as a Customer message through ReplyTicketAction, which moves the ticket to WaitingOnAgent (reopening a Resolved or Closed ticket that way) unless odden-service.reopen_on_customer_reply is false. The response is {"success": true, "merged": false, "messages": [...]} with the full public thread. If the chat's ticket was merged into another, the message is refused: the response is 409 with {"success": false, "merged": true, "error": "...", "notice": "...", "messages": [...]}, where messages is the chat's own (now empty) thread and the notice tells the customer the conversation moved and to check their email. An unknown token returns 404 with {"error": "Chat session not found."}.

Fetch messages

bash
curl https://crm.example.com/api/service/chat/3Ekp9wqpTbwiMh55MGZdv1aOUu3QOWG9kulLvRJf/messages

Response:

json
{
    "ticket_number": "TICK-2026-7WBPJ",
    "status": "open",
    "merged": false,
    "notice": null,
    "messages": []
}

messages has the same shape as above.

A chat session never follows a merge. The chat widget doesn't verify the visitor's email address, so anyone can start a chat in another customer's name, and the chat ticket is attached to that customer's contact. Once the chat's ticket is merged into another ticket, both endpoints keep returning the chat's own ticket (its ticket_number, status, and public messages; the merge moved its messages to the primary, so the list is empty), with merged set to true and notice set to "This conversation has moved to another support ticket. Please check your email for updates from our support team and reply there." The bundled widget shows the notice and hides the message box. The primary ticket's thread, number, and status are never exposed through the chat token. See Replies to merged tickets.

An unknown token returns 404 with {"error": "Chat session not found."}. This endpoint uses the odden-poll limiter (120 requests per minute per IP by default), sized for the widget's polling.