Zoplio · Co-founder and CTO · 2026
A public API for an assistant that books meetings
REST, two SDKs and a hosted MCP server, built June to September 2026.
Zoplio was an AI assistant that books your meetings. The developer API
lets software do what a user does in a chat: say who to meet, and
Zoplio writes to each person over WhatsApp or e-mail, agrees on a time
and books it. I wrote all of the backend; this page is about the part
other developers touch. The API is asynchronous by design: a
create call returns at once with status: negotiating, and
the outcome arrives later, by polling or by a signed webhook.
What it is
The REST API lives at api.zoplio.com under /v1:
create, fetch, list, cancel and reschedule a meeting, read this
month’s usage, and manage webhook subscriptions. The account that
owns the API key is the organizer, so a meeting runs on that
person’s calendar, in their timezone and working hours.
participants holds 1 to 8 people, all of them invited. An
optional organizer field covers the on-behalf case, such as
an agency booking for a client: that person’s calendar is used,
and the meeting still bills to the key’s account.
Around it sit a TypeScript SDK on npm, a Python SDK on PyPI that mirrors it one to one, and a hosted MCP server, so an agent such as Claude can book a meeting as one of its tools. The client libraries, the OpenAPI reference and the examples are open source under MIT. The scheduling engine itself, the negotiation logic and the prompts, runs on the hosted service and is not in the repository; there is no self-hosted mode.
Keys, limits, quotas and errors
Every /v1 route takes Authorization: Bearer zpl_….
Keys are created in the dashboard and shown once. An unknown key gets
401, an expired or inactive one 403, and if
the auth backend itself is down the API answers 503 with
upstream_error, so an outage never reads as “your
key is bad”.
Rate limits are 60 requests a minute per key in a sliding window,
shared by REST and the MCP server, with a ceiling of 300 a minute per
IP across all keys. Responses carry X-RateLimit-Limit and
X-RateLimit-Remaining; a 429 carries
Retry-After in seconds, and rejected requests still count
toward the window.
Billing is per confirmed meeting, so negotiating is free. The free plan
covers 3 confirmed meetings and 15 meeting requests per calendar month
in UTC. A meeting still being arranged holds one of the 3 until it
confirms or falls through, and cancelling it frees the slot. Past
either limit the API returns 402 quota_exceeded with a
message that says which limit it was, and GET /v1/usage
shows what is left.
Every error uses one envelope, {"error": {"code", "message", "details"?}},
with a fixed set of codes: unauthorized,
rate_limited, validation_failed,
not_found, conflict,
quota_exceeded and upstream_error.
validation_failed adds a list of fields and messages. Both
SDKs raise it as one typed error that keeps the code, the HTTP status
and the details.
Webhook URLs are checked when you subscribe: the host is resolved in
DNS, and plain http URLs, hosts that do not resolve and
hosts that resolve to private or internal addresses are rejected.
Safe retries
POST /v1/meetings accepts an X-Idempotency-Key
header of 1 to 128 characters. Replaying a key returns the meeting the
first call created, with its current status, which may already be
confirmed, instead of inviting everyone a second time.
Rescheduling is the harder case. A call looks like “set the slot
to X”, so repeating it looks harmless, but each call opens a new
negotiation round with the participants, and running out of rounds
cancels the meeting. A client that times out and retries could walk a
healthy meeting into cancellation. So reschedule takes the same key,
and a replay returns the original proposal instead of opening another
round. Cancel needs no key: it converges on a state, and cancelling an
already cancelled meeting returns cancelled again.
MCP made this harder, because the protocol has no field to carry a key. The MCP handler for reschedule derives one from the arguments and feeds it into the same idempotency path as REST. The replay cache is scoped to the authenticated user, holds the in-flight result so a concurrent duplicate waits for the same answer, and replays only successes, so a failed attempt stays retryable. I described this, with its limits, in a comment on the MCP specification’s issue about retries of mutating tool calls. The point I would make to any tool author: retry safety cannot be read off the argument shape. Effects that set a target state can be made safe with a state check; incremental ones, such as rounds, messages or charges, need a key.
Signed webhooks
A subscription receives five events: meeting.created,
meeting.confirmed, meeting.rescheduled (with
the slot the meeting left), meeting.cancelled and
negotiation.failed. Its whsec_ secret is
returned once, at creation. Every delivery carries
X-Zoplio-Signature, the hex HMAC-SHA256 of the raw body
keyed with that secret, and X-Zoplio-Delivery-Id, a stable
id of the event.
Delivery is at least once. A receiver has 10 seconds to answer with a 2xx; a failed delivery is retried up to 2 more times, after 1 and then 5 minutes, with the same delivery id, so receivers dedupe on it. After 10 consecutive failures the subscription is disabled.
The signature covers the raw bytes, not the parsed JSON, because re-serializing a parsed body may not give the same bytes. Both SDKs verify it with a constant-time comparison. This is the TypeScript version, unchanged from the repository:
static verifyWebhookSignature( rawBody: string | Uint8Array, signatureHeader: string, secret: string,): boolean { if (!signatureHeader || !secret) return false; const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const provided = signatureHeader.trim().toLowerCase(); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(provided, 'utf8'); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}A runnable webhook receiver in Express shows the whole path: verify the signature over the raw body, skip ids it has already seen, and acknowledge fast.
SDKs and the MCP server
@zoplio/sdk-js
and zoplio on PyPI are
thin clients: one method per endpoint, the idempotency key as an
option on create and reschedule, the typed error, and the webhook
verifier. Each request identifies the SDK and its version in
User-Agent. Continuous integration runs the TypeScript
tests on Node 18, 20 and 22 and the Python tests on 3.10 to 3.14, and a
separate check rejects any literal date in the docs and examples that
is in the past or less than 30 days away, because a copied example
with a stale date would book the past.
The MCP server runs at api.zoplio.com/mcp over stateless
Streamable HTTP, behind the same zpl_ keys and the same
rate limit as REST. It exposes five tools, each mapped one to one to a
REST operation: schedule, get status, list, cancel and reschedule.
Webhook management stays out of it on purpose, since webhooks are an
integration concern and not something an agent does in a conversation.
Connecting Claude Code is one command;
@zoplio/claude-tool documents the setup for
Claude Code, Cursor and Claude Desktop and ships a small config helper.
Known limits
- The SDKs do not retry on their own. Each method
makes one request and raises the typed error on failure; backing off
on
429withRetry-After, and retrying with the same idempotency key, is left to the caller. - The MCP replay cache lives in one process. It keeps entries for 15 minutes, is not durable across restarts or replicas, and cannot tell a retry from a deliberate repeat of the same move inside that window.
- Create over MCP has no durable key. On REST, create is protected by a unique index on the client’s key, but the MCP tool has nowhere to receive one, so it falls back to a fresh id per call. This is why I support a standard key field in the protocol.
- The Python SDK returns plain dicts. Typed return values were planned for a minor release, since they change static types.
Status and links
I co-founded Zoplio and was its CTO from March to October 2026. Zoplio has been on hold since October 2026: on its own, it was a feature rather than a standalone product. The last release of the SDKs is 0.3.1, with 0.1.2 of the MCP package, from 26 September 2026.
API docs · SDK source · OpenAPI reference · @zoplio/sdk-js · zoplio on PyPI · @zoplio/claude-tool · MCP retry discussion
Questions about any of it: hi@kubadvorak.com. Back to the home page.