A reference for the routes this Leadskout actually serves. Replace the placeholders with your own values. This page never shows a live secret.
API documentation
Leadskout has two connections. Muse talks JSON-RPC on a project or account MCP link. A separate API key reads forms and requests over HTTPS as JSON.
Base URL
Every path below is relative to the origin you open the desk on. If the desk is https://desk.example, a form lives at https://desk.example/f/{publicKey} and the REST list lives at https://desk.example/api/v1/forms.
POST /mcp/{key} requires HTTPS. The only exception is a host of localhost, 127.0.0.1, or ::1 while APP_ENV is local. Any other cleartext host gets HTTP 400 and the plain-text body Agents must use https. The REST API and the public form are ordinary web routes on the same host.
API keys
A signed-in person with an active package creates keys on Profile, in the left column under the Twilio number. The name is required and can be at most 40 characters. POST /desk/api-keys. The desk stores a SHA-256 hash and an 8-character prefix. The full key, which starts with desk_, is returned once in that response and is not stored.
Later visits list the prefix, the name, the time it was created, and a Revoke button. A desk can hold several keys. Revoke is POST /desk/api-keys/{id}/revoke. That sets revoked_at, and the key stops working on the next call.
These routes use the signed-in session and the CSRF token. They are not the REST API. The approval secret you create for Muse, and the six-digit desk PIN, stay on their own fields. An API key does not approve a Muse connection. A Muse approval secret does not call /api/v1.
REST API
Prefix /api/v1. The routes are stateless: no session and no CSRF cookie. Send the key as Authorization: Bearer {api_key} or as the X-Api-Key header. Calls are limited to 60 a minute. A missing, unknown, or revoked key returns HTTP 401 and {"message":"A valid API key is required."}
Every query is limited to the user who owns that key. Another account’s form or request returns HTTP 404.
GET /api/v1/forms
Lists the forms on the desk. The Texts inbox is left out. Each item has id, name, url (the public form address), and takes_payment. takes_payment is true when the form charges and the amount is at least 50 cents.
GET /api/v1/forms
Authorization: Bearer {api_key}
GET /api/v1/forms/{id}/requests
Recent submissions for one form you own, 15 to a page. Each item has id, status, created_at, answers_summary, and appointment_at. Times are ISO-8601 in the timezone saved on Profile. When that is unset, they use the application timezone. Rows whose payment_status is neither empty nor paid are left out, the same way the agent connection hides an unpaid request. An id that belongs to another desk, or that is not a form, returns HTTP 404 and {"message":"Unknown form."}
The page body is Laravel’s paginator: data holds the rows, and per_page is 15. Use page to ask for the next page.
GET /api/v1/requests/{id}
One submission you own. The body includes id, form_id, status, created_at, appointment_at, the stored answers, and photos as a list of ids. Photo files are served only to a signed-in desk, so this response does not include a photo URL. An id from another account, or an unpaid request, returns HTTP 404 and {"message":"Unknown request."}
MCP authentication
Muse, ChatGPT, and Claude paste one MCP link. There is no OAuth handshake on this desk. The link is either the account URL, which covers every form, or a form URL, which covers that form.
GET /mcp/{key} without Accept: text/event-stream returns a short JSON note. It does not run a tool. POST /mcp/{key} is the JSON-RPC endpoint, limited to 120 calls a minute. A successful initialize also returns an Mcp-Session-Id header. Later calls may send that header back. Send two headers on every POST and on the event stream:
Authorization: Bearer {approval_secret}— the secret returned once when you approve an agent.X-Desk-Pin: {pin}— the six-digit PIN the owner saved on the desk.
Approve a form agent with POST /desk/projects/{project}/connections while signed in. An optional name may be at most 40 characters. The JSON body includes secret, mcp_url, and a connection object with id, name, and token_prefix. Only a SHA-256 hash of the secret is kept. DELETE /desk/projects/{project}/connections/{connection} sets the approval revoked and returns {"message":"Approval revoked."}
Approve one agent for the whole desk with POST /desk/agent. The same JSON shape comes back, plus revoke_url. DELETE /desk/agent/{connection} revokes that account approval. Those routes need the signed-in session and an active package. They are separate from API keys.
The bearer token is looked up as a hash of an approval on that same URL. A token from another form, a revoked approval, or an API key returns HTTP 401 and This agent has not been approved. Approve it on the project, then send the secret as a bearer token.
After the approval matches, the desk PIN is required. If the owner has not saved a PIN, the 401 message is The owner must create a PIN on the desk before an agent can connect. Ask the user for the desk PIN and send it in the X-Desk-Pin header, then retry the handshake. If the header is missing or the six digits do not match, the 401 message is Ask the user for the desk PIN and send it in the X-Desk-Pin header, then retry the handshake.
A path whose key matches neither an account nor a form returns HTTP 404 and {"message":"Unknown project."} That check happens before the bearer token is considered.
JSON-RPC
POST a JSON object with jsonrpc set to 2.0, a method, an id, and optional params. Successful replies are {"jsonrpc":"2.0","id":…,"result":…} with Cache-Control: no-store and X-Content-Type-Options: nosniff. A message that has no id is a notification. The desk returns HTTP 204 and an empty body.
A body that is not a JSON object returns HTTP 400 and a JSON-RPC error with code -32700 and the message Parse error. An unknown method that still has an id returns HTTP 200 and code -32601, message Method not found.
initialize
Params may include protocolVersion. The desk accepts 2024-11-05, 2025-03-26, and 2025-06-18. Anything else is answered as 2025-03-26. The result includes protocolVersion, capabilities.tools.listChanged set to true, capabilities.logging, serverInfo with name desk and version 1.0.0, and instructions for that connection. A form connection says it is limited to that form. An account connection says it covers every form for that owner. When a form has job instructions or a decision guide, those sentences are included. The response sets Mcp-Session-Id.
POST /mcp/{key}
Authorization: Bearer {approval_secret}
X-Desk-Pin: {pin}
Content-Type: application/json
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}
ping
Returns an empty JSON object as result.
tools/list
Returns result.tools, an array of names, descriptions, and JSON Schema input objects. The schema for form_id changes with the connection: required on the account link, optional on a form link, and if you pass it on a form link it must be that form.
tools/call
Params are name and arguments. A known tool returns result.content, usually one text block whose text is JSON. get_submission can add image blocks (type image, base64 data, and mimeType) for jpeg, png, gif, and webp files that are still on disk. A tool that refuses the arguments returns HTTP 200 with result.isError true and a text explanation. An unknown tool name uses that same shape and the text Unknown tool.
Tools
Follow the decision guide saved on the form before you draft a note, an email, a text, a call outline, a booking, or a suggestion. A form connection only sees that form, plus texts that belong with it. An account connection can pass form_id to pick a form.
Times in tool replies use the timezone saved on Profile. A time you send without an offset is read in that timezone. When Profile has no timezone, times use the application timezone. Appointment instants are stored in that application timezone.
list_forms
No arguments. Returns each form’s form_id, form_name, job instructions, decision guide, instruction flow, and whether the form is open.
list_submissions
Optional status: new, in_progress, or done. Optional form_id. Each row has id, status, created_at, headline, and the form context (form id and name, instructions, decision guide, appointment, and the visitor email when one was stored). Unpaid requests are omitted.
get_submission
Required submission_id. Returns that submission’s answers, visitor email when present, and photo ids, then the image bytes when the file is a supported image. A missing id returns the text That submission is not on this connection.
start_work
Required submission_id. A request that is still new becomes in_progress and records started_at. The reply is the id, status, and form context.
complete_work
Required: submission_id, note, email_subject, email_body, text_message. Optional: call_outline. The desk saves those drafts and marks the request done. It does not send the email or place the call. A missing draft returns The result needs a note, an email subject, an email, and a text message.
list_texts
Optional status of the related submission: new, in_progress, or done. A form connection returns that form’s texts. The account connection includes project_id and the form name. Each row names the direction, who texted, the body, and the status.
send_text
Required body, at most 320 characters. Also pass submission_id or message_id (an inbound or outbound text already stored). Do not pass a phone number. The desk texts the number already stored on that request, from the owner’s Twilio number. SMS that is turned off, a missing number, or a body that is empty or too long comes back as a tool error and leaves the request in place.
suggest_improvement
Required: submission_id, title, suggestion. Optional photo_id when the note is about one uploaded photo. The title is at most 160 characters and the suggestion at most 5000. Call it once per change. The reply includes the new suggestion id, title, photo id, and whether a preview image was saved.
list_booking_forms
No arguments. Lists forms that have an appointment question, with slot_minutes, day_start, day_end, weekdays_only, and timezone. The same timezone is also on the reply.
next_available_slot
On the account connection, form_id is required. On a form connection it is optional and must be that form. Optional from is an ISO datetime and defaults to now. A value without an offset uses the Profile timezone. The reply includes timezone, slot as a Y-m-d H:i label in that timezone, and appointment_at as ISO-8601 with that offset. An unparseable time returns Choose a valid date and time. If nothing is free in the next 60 days, the text is No open time in the next 60 days.
book_appointment
On the account connection, required arguments are form_id, name, and email. On a form connection, form_id is optional and name and email are required. Optional: phone, note, and when. when is an ISO datetime. Without an offset it uses the Profile timezone. If when is omitted, the next free weekday slot is booked. A taken time is refused and the reply names the next free slot in that timezone. A booked appointment is a new submission with booking status confirmed. The reply includes the timezone and the appointment as ISO-8601 in that timezone.
Public form
GET /f/{publicKey} is the form page. POST /f/{publicKey} accepts the request and returns JSON. It is limited to 30 calls a minute. The body is form data, including file parts when the form asks for photos.
answers[{field_id}]— one value per question. A required question that is blank returns that label plusis required.- An email question must be an email address. A phone question needs at least seven digits. A choice question must be one of the saved options. An appointment value must parse as a date that has not already passed.
photos[]— up to six files, jpg, jpeg, png, gif, or webp, each at most 8 MB. If the form requires a photo, an empty set returnsAdd at least one photo.desk_captcha— the code from the current form page, or from GET/captcha, which returns JSON with animagedata URI or atextcode. A wrong code returnsThe code does not match. Request a new one and try again.A missing code returnsEnter the code shown above.viewer_email— required on a private form, and it must match the email saved for that browser by POST/f/{publicKey}/identify(email plusdesk_captcha, 20 calls a minute). Otherwise the POST returns HTTP 422 andEnter your email to open this form.
A closed form, or a form whose owner has no active package, returns HTTP 422 and This form is closed. A desk that has hit its monthly submission limit returns This desk has reached its submission limit for the month. A taken appointment returns That time is already booked. Choose another.
When the form does not take payment, success is HTTP 200 with message Request received. and the new id. When it does, success is Continue to pay. plus id and checkout_url. If Stripe cannot start the charge, the response is HTTP 422, Payment could not be started. Nothing was charged., and the submission is discarded. GET /f/{publicKey}/paid is the page the visitor returns to after checkout. GET /embed/{publicKey}.js serves the embed script for a published form. The public key in that path is letters and digits only.
A request with no payment, or a paid request that becomes visible, emails the owner and the person who wrote an email address when mail is on. The same moment records a notice for each approved agent on that form and on the account connection. An agent that is already holding GET /mcp/{key} open, with Accept: text/event-stream, the bearer token, and the PIN, receives notifications/message and notifications/tools/list_changed on that stream. The stream does not remove the notice. The next initialize, tools/list, or tools/call includes the submission, and that call is what removes the notice. ping does not take it. Leadskout does not call out to a webhook. An agent that has not opened the stream still receives the submission on that next call. A request still waiting for payment is not announced.
Webhooks
An operator points Stripe and Twilio at this desk. Both routes are public POSTs with no CSRF token. They use their own signatures.
POST /stripe/webhook
Send Stripe’s raw body and the Stripe-Signature header. The signing secret is the webhook secret saved in Leadskout, or the Stripe webhook secret in the app configuration when nothing is saved. If that secret is empty, the response is HTTP 500 and Webhook signing secret is not set. A bad signature is HTTP 400 and Invalid signature. A body Stripe cannot parse is HTTP 400 and Invalid payload. A handler failure is HTTP 500 and Webhook could not be processed. Success is HTTP 200 and the body ok.
These event types update the desk: customer.subscription.updated, customer.subscription.deleted, account.updated, v2.core.account.updated, v2.core.account[configuration.recipient].capability_status_updated, v2.core.account[configuration.merchant].capability_status_updated, charge.dispute.created, charge.refunded, and checkout.session.completed (a form payment, or a subscription checkout). Other types are acknowledged and ignored.
POST /twilio/sms
Point the Twilio number’s incoming message URL at /twilio/sms. The request is form fields, including From, To, Body, and MessageSid, plus the X-Twilio-Signature header. The signature is checked against the desk’s webhook URL, the posted fields, and the Twilio auth token saved for SMS. A mismatch returns HTTP 403 and an empty body. A valid message for a number saved on a desk becomes a new submission on Texts. The success body is empty TwiML, <Response></Response>, with content type text/xml. A repeated MessageSid, a number Leadskout does not know, or SMS that is turned off gets that same empty TwiML.
Account routes
These are browser forms. They expect a CSRF token and the captcha field desk_captcha, except logout. They redirect. They are not the REST API and they are not MCP.
- POST
/register—name,email,password, andpassword_confirmation. The password is at least 8 characters. A new account is signed in and sent to packages. - POST
/login—email,password, and optionalremember. A mismatch returnsThose details do not match an account.An account with a confirmed authenticator continues to GET and POST/login/mfa. The POST is limited to 5 calls a minute. - POST
/logout— signed-in only. Ends the session and returns to the home page. - POST
/forgot-password—email, limited to 5 calls a minute. The reply is alwaysIf that email belongs to an account, we sent a reset link.when mail is on. If SMTP is off, the reply isPassword reset email is off until an admin turns on SMTP. - POST
/reset-password—token,email,password, andpassword_confirmation. A bad or expired token returnsThis reset link is invalid or has expired.Success sends the person to log in withPassword updated.
Errors
MCP and the REST API use the codes below. Tool refusals are not HTTP errors. They are JSON-RPC results with isError true.
- HTTP 204 — a JSON-RPC notification (no
id) on POST/mcp/{key}. Empty body. - HTTP 400 — POST
/mcp/{key}without HTTPS, outside the local-host exception. Body:Agents must use https. - HTTP 400 — MCP body is not a JSON object. JSON-RPC code
-32700, messageParse error. - HTTP 200 — unknown MCP method. JSON-RPC code
-32601, messageMethod not found. - HTTP 401 — MCP approval missing or revoked.
This agent has not been approved. Approve it on the project, then send the secret as a bearer token. - HTTP 401 — desk PIN missing or wrong. The message asks for the PIN in the
X-Desk-Pinheader. If no PIN has been saved yet, the message says the owner must create one first. - HTTP 401 — REST call with a missing, wrong, or revoked API key.
A valid API key is required. - HTTP 404 — MCP path with an unknown key.
Unknown project. - HTTP 404 — REST form or request that this key’s user does not own.
Unknown form.orUnknown request. - HTTP 403 — Twilio webhook signature does not match. Empty body.
- HTTP 422 — public form validation, a closed form, a full monthly limit, a taken appointment, or a payment that could not start. The JSON
messageis the sentence listed in Public form. - HTTP 500 — Stripe webhook secret is missing, or the event could not be processed.