Partner integration guide

Connect to Litmus over MCP

How to let your users connect their Litmus account to your product, and call Litmus's tools on their behalf over the Model Context Protocol.

Which orders produce events

Litmus sends webhook events only for order requests created through your integration, once your Litmus contact has registered your endpoint URL and signing secret.

An order request a user creates in Litmus itself, or through another integration, never produces an event for you. Events start with the order request's submission; a draft produces none. If your endpoint is registered after an order request was submitted, you receive events from the next change on.

Event types

Every delivery is a JSON object with type, timestamp (UTC, to the second) and data. Payloads never carry the order's content: no brief, assays or prices. Call get_order_request with order_request_id, or send the user to url, for details.

order_request.stage_changed

Sent each time an order request enters a new stage, starting with in_review when it is submitted. previous_stage is the stage it left, or null on submission.

JSON — order_request.stage_changedShow code
{
  "type": "order_request.stage_changed",
  "timestamp": "2026-10-21T12:00:00Z",
  "data": {
    "order_request_id": "7d1a6b2e-3c4f-4a5b-9e8d-1f2a3b4c5d6e",
    "stage": "sourcing_quotes",
    "previous_stage": "in_review",
    "action_required": false,
    "message": "Litmus is asking labs that can run this order for quotes.",
    "url": "https://platform.litmus.science/order-requests/7d1a6b2e-3c4f-4a5b-9e8d-1f2a3b4c5d6e"
  }
}
order_request.quote_received

Sent once per order request, when the first lab quote comes in. The stage does not change, so previous_stage is always null; stage is the order's current stage.

JSON — order_request.quote_receivedShow code
{
  "type": "order_request.quote_received",
  "timestamp": "2026-10-21T12:00:00Z",
  "data": {
    "order_request_id": "7d1a6b2e-3c4f-4a5b-9e8d-1f2a3b4c5d6e",
    "stage": "sourcing_quotes",
    "previous_stage": null,
    "action_required": true,
    "message": "Quotes are coming in for this order. Sign in to compare labs and pick one to move forward with.",
    "url": "https://platform.litmus.science/order-requests/7d1a6b2e-3c4f-4a5b-9e8d-1f2a3b4c5d6e"
  }
}
integration.test

Sent by hand by your Litmus contact to check that your endpoint receives and verifies a delivery. It carries no order request; data is a fixed marker.

JSON — integration.testShow code
{
  "type": "integration.test",
  "timestamp": "2026-10-21T12:00:00Z",
  "data": {
    "source": "Litmus",
    "test": true
  }
}

The data fields of both order_request.* events:

order_request_id
string

The order request's id, as returned by create_order_request.

stage
string

The order request's stage now. One of the stages below.

previous_stage
string | null

The stage the order request left, or null when the event isn't a stage change or the order had no stage yet.

action_required
boolean

true when the user has to do something in Litmus for the order to move on; false when the event is informational.

message
string

One sentence describing the event, written for the user. Safe to show as is.

url
string

The order request's page in Litmus, where the user acts on it.

Stages and action_required

A submitted order request moves through these stages in order, each announced by an order_request.stage_changed event. action_required is true when the user has to act in Litmus for the order to move on.

in_review
action_required: false

Submitted. Litmus is reviewing the order before asking labs for quotes.

sourcing_quotes
action_required: false

Litmus is asking labs that can run the order for quotes.

confirm_order
action_required: true

A lab has been selected. The user signs in to pay and sign the standard agreements so work can start.

in_lab
action_required: false

The order is confirmed and the lab has started work.

order_request.quote_received normally arrives while the order is sourcing_quotes (a lab can still quote until the order is paid, so stage reports the order's stage at the time) and sets action_required to true: the user signs in to compare labs and pick one. The order request object has a later results stage; no event is sent for it yet.

Verifying a delivery

Every delivery is signed to the Standard Webhooks specification with the secret you gave Litmus. Verify it before acting on the body.

webhook-id
msg_…

The event's id. It is the same on every delivery attempt of that event, so it is the key to dedupe on.

webhook-timestamp
Unix seconds

When this attempt was sent. A retry carries a fresh timestamp and signature, so verification with the usual five-minute tolerance passes for retries too.

webhook-signature
v1,…

HMAC-SHA256 over the id, the timestamp and the raw body, as a Standard Webhooks signature. Verify against the request body bytes as received, not a re-serialised copy.

Secret
whsec_…

A Standard Webhooks secret: whsec_ followed by base64. You generate it and give it to your Litmus contact; Litmus stores it encrypted. Give a new one to rotate.

Use a Standard Webhooks library for your stack rather than checking the signature by hand. Reply with any 2xx status once you have stored the event; the response body is ignored.

Python — standardwebhooksShow code
from fastapi import FastAPI, Request, Response
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = FastAPI()
# The whsec_... secret you gave Litmus. Your own config, never in client code.
webhook = Webhook(LITMUS_WEBHOOK_SECRET)

@app.post("/litmus/webhooks")
async def litmus_webhook(request: Request) -> Response:
    headers = {
        name: request.headers[name]
        for name in ("webhook-id", "webhook-timestamp", "webhook-signature")
    }
    try:
        # Verifies the signature over the raw body and the timestamp's freshness,
        # then returns the parsed body: {"type", "timestamp", "data"}.
        event = webhook.verify(await request.body(), headers)
    except WebhookVerificationError:
        return Response(status_code=401)

    # Litmus may deliver an event more than once; the id is the same each time.
    if await already_handled(headers["webhook-id"]):
        return Response(status_code=200)
    await handle_litmus_event(event)  # keep this quick; reply within 10 s
    return Response(status_code=200)

Retries and ordering

Delivery is at least once. Dedupe on webhook-id.

Delivered
2xx

Any 2xx response marks the event delivered. The first attempt is made within seconds of the change.

Retried
408, 429, 5xx, no response

Those statuses, a connection error, or no response within 10 seconds are retried with the same webhook-id and body. The wait doubles from 30 seconds per attempt, capped at one hour, with jitter (each wait is between half and the full step).

Not retried
Any other status

A 3xx or another 4xx is treated as a final answer: the event is not sent again. Redirects are not followed.

Horizon
24 hours

Litmus stops retrying an event 24 hours after it was recorded. Missed events are not replayed; get_order_request shows the order's current state.

Events are ordered per order request: an event is not sent while an older event for the same order request is still awaiting delivery, so a retrying event holds back the ones after it for that order until it is delivered or passes the horizon. Events for different order requests are independent. A crash between a delivery and its acknowledgement can send an event again under the same webhook-id, which is why delivery is at least once.

Your endpoint URL

Give your Litmus contact an https URL and the signing secret. Both can be changed later; sending a new secret replaces the old one.

The URL is stored in normalised form: the host is lowercased, a default port is dropped and a bare host gets a trailing slash, so https://Example.com is stored and called as https://example.com/. Paths and query strings are kept as given. Only https is accepted. Events are only recorded while both the URL and the secret are set; see which orders produce events.