The Kommandr OS API

A REST API over your own data — inventory, sales, catches, contacts, expenses and the calendar — plus signed webhooks so another system can react the moment something happens. Your business does not end at our walls.

Base URL
https://www.kommandros.com/api/v1

Authentication

Every request carries an API key. Create one in Kommandr Konnect → Integrations (bottom of the page) → New key. Keys are shown once at creation and stored only as a hash. If you lose one, revoke it and make another.

curl https://www.kommandros.com/api/v1/me \
  -H "Authorization: Bearer kmdr_live_..."

# Or, if your platform sends a plain header:
curl https://www.kommandros.com/api/v1/me -H "X-API-Key: kmdr_live_..."

GET /me is the endpoint to test a connection against. It returns your account identity and the key's scope.

Endpoints

GET/meneeds read

Who this key belongs to. Use it to test a connection.

Returns: { id, email, business_name, scope }

GET/itemsneeds read

Inventory. Filter by status, category and age; sort by age or value.

Query: status, category, age_gt, sort, limit, cursor

Returns: { data: [ { id, title, category, status, cost_cents, list_price_cents, days_listed, description?, photos?, channels? } ], next_cursor }

POST/itemsneeds write

Add an item to Inventory. Same row the Inventory & Profit form writes: status in_stock puts it on your website on the next request; draft keeps it in Inventory only.

Body: { title, price_cents, description?, cost_cents?, category?, condition?, photos?: [https URL], status?: draft|in_stock|listed }

Returns: 201 · the item, in the same shape as a GET /items row (adds description, photos, condition when present)

Money is integer cents. Photos must be https URLs. cost_cents omitted means "cost unknown" — it is never recorded as $0.

GET/items/{id}needs read

One item.

Returns: { id, title, category, status, cost_cents, list_price_cents, days_listed, description?, photos?, condition?, sold_at?, sold_price_cents?, sold_via?, channels? }

PATCH/items/{id}needs write

Edit an item. Whitelisted fields only; anything else in the body is ignored. Edits to a cross-listed item are mirrored onto the listing your store shows.

Body: { title?, description?, price_cents?, cost_cents?, status?: draft|in_stock|listed, category?, condition?, photos? }

Returns: The updated item.

status:"sold" is refused here. Record a sale with POST /items/{id}/sold so the item comes off your store and every marketplace and the sale reaches your books.

POST/items/{id}/soldneeds write

Record a sale. Runs the same loop as the Sold button: off your storefront, take-downs queued on every marketplace it was posted to, the sale booked in your ledger, the buyer linked in your CRM with the order confirmation and a pickup/delivery scheduling link.

Body: { sale_price_cents?, channel?, net_of_fees?: false, buyer_contact_id?, buyer_name?, buyer_email?, buyer_phone? }

Returns: { item, delist: { status: queued|off|none|error, message, queued, platforms }, books_recorded, crm: { linked, contact_id? } } · 409 already_sold if it was

sale_price_cents is the GROSS price unless net_of_fees is true. delist.status is the honest word: "queued" means the take-downs are on the worker queue, not that they have happened.

GET/catchesneeds read

Listings Deals has found for your hunt filters.

Query: status, since, limit, cursor

Returns: { data: [ { id, title, price_cents, location, distance_miles, url, photo_url, found_at } ], next_cursor }

POST/catches/{id}/claimneeds write

Turn a catch into a draft Inventory item with the deal price as its cost basis — what Confirm Pickup does in the app. With scheduled_for AND address it also books the pickup on your Calendar and files a Pipeline card; without them it claims into Inventory only. Idempotent per catch.

Body: { scheduled_for?, address?, seller_phone?, notes? }

Returns: { item, catch_id, pickup: { id, scheduled_for, address } | null, deduped } · 201 on first claim, 200 when already claimed

GET/expensesneeds read

Your expense ledger — the same rows Money > Expenses lists and your P&L deducts.

Query: since (YYYY-MM-DD), category, limit, cursor

Returns: { data: [ { id, amount_cents, category, merchant, date, source, note?, item_id? } ], next_cursor }

POST/expensesneeds write

Log an expense. Lands in the same ledger as a scanned receipt, so it is in your P&L and tax estimate the moment it saves.

Body: { amount_cents, category?, merchant?, note?, item_id?, date?: YYYY-MM-DD }

Returns: 201 · the expense

Categories: Materials & Supplies, Tools & Equipment, Fuel & Vehicle, Meals, Office, Software & Subscriptions, Marketing, Rent & Utilities, Shipping & Postage, Professional Services, Travel, Insurance, Taxes & Fees, Other. Short forms like "fuel" or "supplies" are accepted; anything unrecognised is refused rather than filed under Other.

GET/jobsneeds read

Pickups and deliveries on your Calendar, soonest first.

Query: type (pickup|delivery), status, from, to, limit, cursor

Returns: { data: [ { id, type, title, starts_at, ends_at?, status, address?, notes?, price_cents?, contact_id?, item_id?, name?, phone? } ], next_cursor }

POST/jobsneeds write

Book a pickup or delivery. Same path as a booking from a call or your website form: the buyer is resolved into your CRM, the event lands on your Calendar, and the reminder fires an hour before.

Body: { type: pickup|delivery, starts_at, ends_at?, contact_id?, item_id?, address?, notes?, name?, phone?, title? }

Returns: 201 · the job

A "pickup" is the buyer collecting from you; a "delivery" is you taking it to them. To book yourself collecting a catch, use POST /catches/{id}/claim.

GET/contactsneeds read

Your buyers and leads.

Query: limit, cursor

Returns: { data: [ { id, name, email, phone, status, source, pipeline_stage } ], next_cursor }

POST/contactsneeds write

Add a lead. Needs at least one of name, email or phone.

Body: { name?, email?, phone?, source?, notes? }

Returns: The created contact.

Marketing and SMS consent are never set through the API. Consent is something a person gives, not a field an integration writes.

GET/threads/{contactId}/draftneeds read

The pending AI draft on a buyer thread, if any. Drafts carry their provenance and the data they were based on.

Returns: { draft: { id, body, status, badge, based_on, created_at } | null }

PUT/threads/{contactId}/draftneeds propose

Write a reply draft into a thread. One pending draft per thread — a new one replaces it. Drafts pass a quality guard (no outcome promises, no silent under-floor prices, no off-platform phrasing) or are refused with reasons. There is NO send endpoint: sending is done by a person, in the app, always.

Query: body, agent_label, context_used

Returns: { draft } · 422 with reasons if the guard refuses it

DELETE/threads/{contactId}/draftneeds propose

Discard the pending draft on a thread.

Returns: { ok: true }

POST/messagesneeds write

Log a buyer message your agent read or sent — in or out — so the conversation lives in Messages beside your texts and emails. An inbound message fires message.received and notifies your phone. Idempotent on (thread, direction, text, sent_at).

Body: { channel: marketplace|ebay|sms|email|other, marketplace?, direction: in|out, external_thread_id, buyer_name?, buyer_phone?, buyer_email?, listing_url?, item_id?, text, sent_at? }

Returns: 201 · { message: { id, direction, contact_id, item_id?, sent_at }, contact: { id, name }, thread: { external_thread_id, channel }, deduped } · 200 with deduped: true when already logged · 429 daily_cap past 1,000 a day

This records a message; it never sends one. Sending is done by a person, or by the agent in your own marketplace account.

GET/threadsneeds read

Your conversations, newest first — the same threads Messages shows, with the external thread id and item when an agent logged them.

Query: unread (true), limit, cursor

Returns: { data: [ { contact_id, external_thread_id?, channel, marketplace?, buyer_name, buyer_phone?, item_id?, item_title?, last_message_at, last_message_preview, last_direction, unread } ], next_cursor, truncated }

GET/threads/{contactId}/messagesneeds read

One conversation, oldest to newest, including replies you typed in the app. Reading does not mark anything read.

Query: limit

Returns: { data: [ { id, direction: in|out, channel, text, subject?, sent_at, kind?, status?, item_id? } ], has_more, contact: { id, name, phone?, email? } }

GET/items/{id}/contextneeds read

What an agent needs to negotiate one item: the floor price and where it came from, condition, dimensions, photos, pickup area and windows, and whether the exact address may be shared yet.

Returns: { id, title, status, list_price_cents, floor_price_cents, floor_basis: owner_floor_pct|cost_x1_5|list_x0_85|list_price, condition?, dimensions?, description?, photos?, category?, pickup_area?: { area?, zip? }, pickup_windows?, days_listed, address_release_allowed }

pickup_area is city/state and ZIP only. The street address is never returned by the API; address_release_allowed turns true once POST /pickups/confirm has booked a pickup.

POST/pickups/confirmneeds write

A buyer agreed a pickup time: book it on your Calendar through the same path as every booking, link the buyer and item, notify your phone, set the one-hour reminder, and push it to Google Calendar when connected.

Body: { item_id?, contact_id?, external_thread_id?, buyer_name?, buyer_phone?, starts_at, ends_at?, notes?, agreed_price_cents? }

Returns: 201 · { job, contact, address_release_allowed: true, google_calendar: synced|off|not_connected|pending|error, reminder }

Does not change the item's status and does not mark it sold — record the sale with POST /items/{id}/sold when it happens.

POST/catchesneeds write

Submit a listing your agent found in your own marketplace account into Deals. Same intake as the scanner: scam check, dedupe on (platform, external_id), catch.created, and a phone alert only when posted_at proves it is fresh.

Body: { platform, external_id, listing_url, title, price_cents?, location?, posted_at?, image_urls?, description?, seller_name? }

Returns: 201 · { catch, created: true, freshness } · 200 { created: false, deduped: true } · 200 { created: false, skipped: "stale" } · 429 daily_cap past 200 a day

The row is stamped source: "agent" so Deals can say who found it.

GET/outboxneeds write

Replies you typed in Messages on a marketplace thread that your agent has not delivered yet, oldest first. The agent sends each one in your own marketplace account and confirms below. Kommandr sends nothing itself.

Query: limit

Returns: { data: [ { id, contact_id, external_thread_id, channel, marketplace?, item_id?, text, created_at } ] }

Write scope because reading the queue is the first half of an action you asked for. A row stays here until POST /outbox/{id}/delivered settles it.

POST/outbox/{id}/deliveredneeds write

The agent reports a queued reply as sent on the marketplace — or as failed, with the reason. Messages flips the bubble from "Waiting for your agent to deliver" to "Delivered by your agent".

Body: { delivered_at?, external_message_id? } · or { failed: true, reason }

Returns: { id, delivery: delivered, delivered_at } · { id, delivery: failed, reason } · 409 not_queued if the row was never queued for an agent

Idempotent: a settled row is returned as it is. Logging the same text through POST /messages (direction out) settles the row too, so a thread never shows the reply twice.

GET/hunt-filtersneeds read

Your Deals hunt filters, read-only: what to look for, the price band, the radius.

Returns: { data: [ { id, name, keywords, exclude_keywords, min_price_cents?, max_price_cents?, zip?, radius_miles, condition, platforms, active } ] }

POST/items/{id}/crosspostneeds write

Queue an item onto marketplaces it has been posted to before — the same queue, daily cap and quiet hours as cross-posting from the app. A platform the item was never listed on is skipped with a reason.

Body: { platforms: [ "ebay", … ] }

Returns: 202 · { item_id, status: queued|nothing_queued, queued, skipped: [ { platform, reason } ], posting_window?: { quiet, timezone, resumes_at? } }

"queued" means on the worker queue, not live. listing.posted fires when each copy is up.

POST/items/{id}/delistneeds write

Take an item down from every marketplace it is live on, through the same take-down queue the Sold button uses. Does not mark it sold.

Returns: 202 · { item_id, status: queued|off|none|error, queued, platforms, message }

status is the honest word: "queued" is on the queue, "off" means the take-down worker is disabled, "none" means nothing was live.

GET/social/accountsneeds read

Your connected social accounts and what each can carry.

Returns: { data: [ { id, platform, username, active, needs_reconnect, caps: { text, image, video, max_images, max_chars, needs_media } } ], connected }

POST/social/postsneeds write

Draft or schedule a social post on your connected accounts. Without scheduled_for it is saved as a draft you publish from Social; with it, it is scheduled and you can pull it before it goes. There is no post-now through the API.

Body: { platforms: [ "instagram", … ], caption?, media_urls?, scheduled_for? }

Returns: 201 · { status: draft|scheduled, platforms, skipped?, post_id?, scheduled_for?, where }

GET/hooksneeds read

Your webhook subscriptions, with their health.

Returns: { data: [ { id, event, target_url, active, failures, last_error, last_success_at } ] }

POST/hooksneeds write

Subscribe an https URL to an event.

Body: { event, target_url }

Returns: { id, event, target_url, secret } — the secret is shown once and never again.

DELETE/hooks/{id}needs write

Unsubscribe. Returns 204 whether or not the subscription existed.

Returns: 204 No Content

Webhooks

Subscribe an https endpoint to an event and we POST to it when the event happens. Every delivery is signed, retried with backoff on 5xx, and a subscription that fails repeatedly is switched off with the reason recorded — so a stopped integration has an answer rather than a silence.

Events

  • catch.created — New catch found
  • listing.posted — New listing posted
  • listing.sold — Listing sold
  • listing.failed — Posting failed or needs reconnect
  • message.received — New message received
  • sale.created — New sale recorded
  • payment.received — Payment received
  • job.scheduled — Pickup or delivery scheduled
  • item.stale — Item unsold for 30 days

Subscribing

curl -X POST https://www.kommandros.com/api/v1/hooks \
  -H "Authorization: Bearer kmdr_live_..." \
  -H "Content-Type: application/json" \
  -d '{"event":"listing.sold","target_url":"https://example.com/hooks/kommandr"}'

# → { "id": "...", "event": "listing.sold", "secret": "whsec_..." }
#   The secret is shown once. Store it.

Payload

{
  "event": "listing.sold",
  "id": "b0f1...",                       // unique per delivery — use it to dedupe
  "occurred_at": "2026-08-25T14:03:11.000Z",
  "data": { ... }
}

Verifying the signature

Each delivery carries a Kommandr-Signature header of the form t=<unix>,v1=<hex>. The HMAC is over timestamp + "." + rawBody, so a captured delivery cannot be replayed later against a receiver that checks the age. Verify before you act on it — your endpoint is public and anyone who learns the URL can post to it.

const crypto = require('crypto')

function verify(secret, rawBody, header, toleranceSec = 300) {
  const m = /t=(\d+),v1=([a-f0-9]+)/i.exec(header || '')
  if (!m) return false
  const ts = Number(m[1])
  if (Math.abs(Date.now() / 1000 - ts) > toleranceSec) return false
  const expected = crypto.createHmac('sha256', secret)
    .update(ts + '.' + rawBody).digest('hex')
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(m[2], 'hex')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

MCP (for AI agents: Muse, Claude, ChatGPT)

The same API, spoken as Model Context Protocol so a personal AI agent can use it as a custom connector. Add the URL below to the agent with a Kommandr key as the bearer token; it will discover the tools itself. Every tool is the same handler as its REST twin; scopes and refusals are identical. There is no send or delete tool: log_message records a message the agent read or sent in your own account, list_outbox hands the agent the replies you typed so it can send them in your own account, and delist_item queues marketplace take-downs without deleting a record.

MCP URL
https://www.kommandros.com/api/mcp
Transport
Streamable HTTP (JSON-RPC 2.0 over POST)
Auth
Authorization: Bearer kmdr_live_...

Tools

  • get_me
  • list_items
  • get_item
  • create_item
  • update_item
  • mark_sold
  • list_catches
  • claim_catch
  • list_jobs
  • create_job
  • list_contacts
  • create_contact
  • list_expenses
  • log_expense
  • get_reply_draft
  • write_reply_draft
  • discard_reply_draft
  • log_message
  • list_threads
  • get_thread_messages
  • get_item_context
  • confirm_pickup
  • submit_catch
  • list_hunt_filters
  • list_outbox
  • mark_delivered
  • crosspost_item
  • delist_item
  • list_social_accounts
  • create_social_post
curl -X POST https://www.kommandros.com/api/mcp \
  -H "Authorization: Bearer kmdr_live_..." \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Connecting a personal agent rather than writing code? The exact instructions a member pastes into their agent are published as the Playbook for Muse → /muse.

Conventions

Errors
Always `{ "error": { "code": "...", "message": "..." } }` with the HTTP status. The code is stable and machine-readable; the message is for a human and may change.
Money
Always integer cents, never decimals. `list_price_cents: 34000` is $340.00. Floating-point dollars are how an automation ends up emailing somebody about $84.50000001.
Dates
Always ISO 8601 with a timezone, e.g. `2026-08-25T14:03:11.000Z`.
Pagination
Cursor-based. A list response carries `next_cursor`; pass it back as `?cursor=`. `null` means the end. Offset pagination silently skips rows when new ones arrive mid-page, which is exactly what happens on a busy account.
Limits
`?limit=` defaults to 50 and is capped at 200.
Rate limit
120 requests a minute per key. Over it you get 429 with a `Retry-After` header.
Scopes
A key is read, write or propose. A write endpoint called with a read key returns 403 `insufficient_scope` and names the scope it needed.
Missing data
Fields we do not have are omitted, not returned as 0 or null. A zero claims something was measured; absence is the truth.

What no key can do

Published rather than hidden, because the ceiling on an integration is something you should be able to check before you install it. These are refused regardless of scope — there is no key, and no plan, that reaches them.

  • ✕Release or edit a payout, or change a payout method or bank detail
  • ✕Delete any record
  • ✕Read or change billing, subscription or plan
  • ✕Change team member permissions
  • ✕Create or revoke API keys

Something missing? Email support@kommandros.com. The API is versioned — /api/v1 keeps its shape, and anything that changes it gets a new version rather than breaking what you built.