Docs
MCP server
Connect Claude Code, Claude Desktop, Cursor or any MCP client to anymd.cc/mcp so your agents can read the public web, remember it and search it, and get webhook events when it changes.
anymd runs a remote Model Context Protocol server. It is the main way agents use anymd as their web context layer: connect it once and your agent can read public web content as structured Markdown, recall everything it has read from your private library, and (with the right scopes) manage pages.
| Endpoint | https://anymd.cc/mcp |
| Transport | Streamable HTTP, stateless JSON responses |
| Protocol | 2026-07-28 (stateless) and 2025-11-25 and earlier |
| Auth | Authorization: Bearer amd_…, or OAuth 2.1 |
Authentication
API key. Simplest option. Create a key in Dashboard → API keys. The Convert + library (agents) preset is a good default for an assistant. Details: API keys & roles.
OAuth 2.1. For clients that support it, just add the URL and sign in when prompted. anymd supports Dynamic Client Registration and PKCE:
| Metadata | /.well-known/oauth-authorization-server |
| Authorize | /oauth/authorize |
| Token | /oauth/token |
| Registration | /oauth/register |
Either way, your agent only sees the tools its scopes allow. An OAuth client that requests no scopes gets convert, library:read and library:write (read the web, save to and search your own library); anything more, admin scopes above all, must be requested by name and approved on the consent screen, or (owners and admins only) added by choosing an admin access option there: see Set up an admin agent. When a read is not saved, the result says so: saved: false with not_saved_reason (missing_scope means the connection lacks library:write). See API keys & roles.
Client setup
Run anymd mcp from the CLI to print these snippets with your key filled in.
Claude Code
claude mcp add --transport http anymd https://anymd.cc/mcp \
--header "Authorization: Bearer amd_…"
Claude Desktop
Add anymd as a custom connector: Settings → Connectors → Add custom connector, URL https://anymd.cc/mcp, then sign in through OAuth.
If you'd rather use an API key, bridge the remote server through mcp-remote in claude_desktop_config.json:
{
"mcpServers": {
"anymd": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://anymd.cc/mcp", "--header", "Authorization: Bearer amd_…"]
}
}
}
Cursor
~/.cursor/mcp.json (global) or .cursor/mcp.json (per project):
{
"mcpServers": {
"anymd": {
"url": "https://anymd.cc/mcp",
"headers": { "Authorization": "Bearer amd_…" }
}
}
}
Any other client
Any client that speaks Streamable HTTP can use the same shape: URL https://anymd.cc/mcp plus an Authorization header, or OAuth discovery from the metadata URL above.
Tools
Tools are listed only when the caller holds the scope they need.
Feedback
| Tool | Input | Needs |
|---|---|---|
submit_feedback |
{ type, title, description, steps_to_reproduce?, expected_behavior?, actual_behavior?, reference_url?, context_page?, confirm_public, idempotency_key } |
feedback:write |
list_my_feedback |
{ limit? } |
feedback:write |
submit_feedback publishes a public GitHub issue and cannot be undone: call it only when your user asked to send feedback and approved the text. See Send feedback.
Conversion and library
| Tool | Input | Needs |
|---|---|---|
read_url |
{ url, save?, fresh?, removeImages?, expandThread?, maxThreadPosts?, includeComments?, analyzeImages?, maxComments?, maxImages?, maxCredits? } |
convert (saving also needs library:write) |
convert_url |
Same input as read_url |
Same as read_url, kept under its original name for existing clients |
get_video_download |
{ id } |
convert |
search_library |
{ query, mode?, limit? } |
library:read |
search_social |
{ platform, query, cursor?, period?, since?, until? } (platform may be all) |
convert (account required) |
get_document |
{ id } |
library:read |
list_documents |
{ limit?, domain?, tags?, before? } |
library:read |
list_tags |
{ limit? } |
library:read |
tag_document |
{ id, add?, remove?, set? } |
library:write |
delete_document |
{ id } |
library:write |
usage_summary |
{} |
usage:read |
search_social searches public posts on X, Facebook, Instagram, Threads, LinkedIn or all of them and returns normalized results with a per-platform status and a next_cursor. period (24h, 7d, 30d) or since/until (ISO-8601) limits it to a time range; posts are de-duplicated across every page of the search, and the cursor keeps the range, works once and lasts 24 hours. See Social search. read_url and convert_url are the same tool with the same input and output; new integrations should use read_url. mode is hybrid (default), bm25, fulltext or semantic. See Library & search.
tag_document adds and/or removes tags, or replaces them all with set (not combinable with add/remove; [] clears), and returns { id, tags }. Tags are lowercased and keep only letters, digits, - and _; a document holds at most 20, and an edit past that fails with too_many_tags instead of dropping tags. list_tags returns { items: [{ tag, count }] }, most used first. list_documents with tags keeps documents carrying every listed tag (whole-tag match, up to 10).
expandThread, includeComments and analyzeImages are opt-in deep reading flags that cost extra credits. Options the agent leaves out follow the account's saved deep reading defaults, otherwise they are off: connecting an agent never turns enrichment on by itself. maxThreadPosts defaults to 20 and is capped at 100; maxCredits defaults to 100 and is capped at 1,000; maxComments defaults to 100 and is capped at 1,000; maxImages defaults to 10 and is capped at 20. removeImages strips image/media URLs at no credit cost. X reads return the single requested post unless thread expansion is enabled. The response's reading_options shows each effective value and its source (request, preference or default). Social post adapters for Facebook, Instagram, Threads and LinkedIn require an account. The response includes credit_breakdown and enrichment coverage so partial provider, limit or timeout results are explicit; cached reads are free.
With downloadVideo: true (or the saved setting), a YouTube read_url also starts a background download of the lowest-quality video to the anymd CDN and returns video_download.id; the Markdown says the same. Call get_video_download with that id every 15–30 seconds until status is ready (cdn_url is the MP4) or failed. 20 credits are charged only when ready. See Sources → YouTube. With analyzeVideo: true (or the saved setting; it turns on the download too), the same job then gets an AI analysis of the video by google/gemini-3.8-flash (10 + 20 credits per started minute, charged when ready): keep calling get_video_download until analysis.status is ready (analysis.markdown) or failed.
Pages and posts
| Tool | Input | Needs |
|---|---|---|
list_blocks |
{} |
pages:read |
list_page_templates |
{} |
pages:read |
list_pages |
{} |
pages:read |
get_page |
{ id } |
pages:read |
create_page |
{ slug, title, description?, template? } |
pages:write |
apply_page_ops |
{ pageId, baseRevision, ops, idempotencyKey? } |
pages:write |
publish_page |
{ pageId, revision? } |
pages:publish |
unpublish_page |
{ pageId } |
pages:publish |
list_posts |
{} |
content:read |
upsert_post |
{ id?, slug?, title, markdown, excerpt?, tags? } |
content:write |
publish_post |
{ id, publish } |
content:publish |
get_page_markdown |
{ pageId } |
pages:read |
list_page_revisions |
{ pageId } |
pages:read |
rotate_page_preview_token |
{ pageId } |
pages:write |
archive_page |
{ pageId } |
pages:write |
get_post |
{ id } (id or slug) |
content:read |
fork_post |
{ slug } (copy a bundled post into the CMS) |
content:write |
delete_post |
{ id } |
content:write |
The ops format, revision rules and the safe editing loop are in the admin builder guide.
Your account
| Tool | Input | Needs |
|---|---|---|
list_traces |
{ cursor?, limit? } |
usage:read |
get_trace |
{ traceId } |
usage:read |
list_api_keys |
{} |
keys:manage |
create_api_key |
{ name, preset?, scopes?, expiresInDays? } |
keys:manage |
revoke_api_key |
{ keyId } |
keys:manage |
list_oauth_grants |
{} |
keys:manage |
revoke_my_oauth_grant |
{ grantId } |
keys:manage |
create_api_key returns the secret once; a new key never gets scopes the calling credential lacks. OAuth connections cannot create keys at all (oauth_key_creation_forbidden), because a key would outlive the grant: create keys with an API key, a session, or in the dashboard.
System administration
Owners and admins can run the deployment from an agent: users, credentials, credits, settings, opt-outs, billing health, audit history and system usage. Every admin tool is a thin adapter over the same services the REST API uses, so the rules are identical everywhere:
- Scoped. Each tool needs one granular scope and is hidden without it. The one exception is
get_admin_access_status, which every signed-in caller sees because it reports only on the caller. Owner-only tools (update_user_role,grant_credits,revoke_credit_grant) never appear for admins. - Ranked. Nobody can act on their own account; admins only act on accounts ranked below them.
- Bounded. Every list is paginated: pass
next_cursorback ascursor. There is no SQL or shell tool. - Safe to retry. Mutations accept an
idempotencyKey(required forgrant_credits); a retry returns the first result withreplayed: true, and a retry that arrives while the first attempt is still running getsidempotency_in_progress(retry shortly) instead of running the change twice. Changes guarded byexpectedRoleorexpectedVersionfail with a conflict instead of overwriting someone else's change. - Audited. Every change writes an audit row with the acting user, the API key or OAuth client, the tool (
via: mcp:<tool>), the request id and a minimal diff. Secrets are never recorded or returned. - Billing stays with the provider. Plans and subscriptions are read-only here. Use credit grants for support allowances: an active grant raises their allowance. By default a grant expires at the end of the current month (UTC), or at the end of next month when fewer than 7 days of this month remain; a grant without
recurringis a one-time pool: its credits are spent once over its whole lifetime, so whatever one month uses is gone the next (5,000 granted on the 26th with 3,000 used that month leaves at most 2,000 for next month). Passrecurring: true(with or withoutexpiresAt) for credits that renew in full every month; anexpiresAtlater than the default withoutrecurring: trueis422. On Pro and Scale, credits are used in this order: the plan's included credits, then grants, then paid overage. A grant covers usage from the moment it is granted onward and is not a refund: overage reported before it stays billed. Example: on a plan with 10,000 included credits, a 5,000-credit grant made when the account had used 12,000 this period keeps the next 5,000 credits (12,000 to 17,000) off the overage bill; the 2,000 used before the grant stay billed. Positions are counted within the current period of a monthly subscription, or the calendar month (UTC) otherwise.
Tool annotations tell the client what to expect: read tools are readOnlyHint, and tools that remove access or data (set_user_status, revoke_*, remove_site_optout, update_settings, delete_post, archive_page) are destructiveHint, so a well-behaved client asks before running them.
Read tools
| Tool | Input | Needs |
|---|---|---|
get_admin_access_status |
{} |
any signed-in caller |
system_overview |
{} |
system:read |
get_user_stats |
{} |
system:read |
get_operations_health |
{} |
system:read |
list_failed_jobs |
{ type?, since? (at most 30 days ago), userId?, cursor?, limit? } |
system:read |
get_job |
{ jobId } |
system:read |
list_system_usage |
{ days? (1–30, default 7), channel?, kind? } |
system:read |
list_system_traces |
{ sort?, status?, kind?, userId?, since? (at most 30 days ago), cursor?, limit? } |
system:read |
get_system_trace |
{ traceId } |
system:read |
list_users |
{ search?, role?, plan?, status?, createdAfter?, createdBefore?, lastLoginAfter?, lastLoginBefore?, cursor?, limit? } |
users:read |
get_user |
{ userId } |
users:read |
list_user_credentials |
{ userId } |
users:read |
get_user_activity |
{ userId, since? (at most 30 days ago), until?, cursor?, limit? } |
users:read |
list_roles |
{} |
users:read |
list_audit_events |
{ action?, actorUserId?, target?, targetType?, since?, until?, cursor?, limit? } |
audit:read |
list_subscriptions |
{ status?, plan?, userId?, cursor?, limit? } |
billing:read |
get_subscription |
{ subscriptionId } |
billing:read |
get_billing_diagnostics |
{} |
billing:read |
get_customer_billing_summary |
{ userId } |
billing:read (credit grants also need credits:read) |
list_billing_events |
{ status?, provider?, type?, outcome?, since? (at most 90 days ago), cursor?, limit? } |
billing:read |
get_billing_event |
{ eventId } |
billing:read |
list_credit_grants |
{ userId?, state?, source?, cursor?, limit? } |
credits:read |
list_site_optouts |
{ search?, cursor?, limit? } |
optouts:read |
get_settings |
{} |
settings:read |
Timestamps accept epoch milliseconds or ISO 8601. action in list_audit_events takes an exact action or a prefix such as user.*.
What the reports count
get_user_stats returns each number with its definition, the windows and a list of data coverage caveats, so the response is the reference. In short:
- Active users (DAU/WAU/MAU) are distinct signed-in users with usage (any channel, success or error) or a browser sign-in in the last 24 hours, 7 days or 30 days. Windows are rolling, in UTC, and end at
generated_at. Anonymous usage is excluded. - Usage-active users count usage only, without sign-ins, and are reported separately.
- Activated users have at least one document in their library. Creating an account is not activation.
- Paying accounts are users whose latest subscription entitles a non-free plan under the live billing provider's rules. The stored plan (
users.plan) is reported separately, along with conflicts between the two. - Coverage. Sign-ins come from sessions, which are deleted at sign-out and expiry, so sign-in activity can be undercounted. No bot, internal or test accounts are excluded. A user who deletes every document no longer counts as activated.
anymd stores the subscriptions that billing webhooks sync, not orders, invoices or payments. No tool reports revenue or MRR, and get_customer_billing_summary returns orders, invoices, payments, refunds and customers under unsupported instead of placeholders: look them up in the billing provider. The summary masks the user's email, and lists recent credit grants only when the caller also holds credits:read.
get_user_activity returns event metadata without URLs, search queries or document contents. get_operations_health, list_failed_jobs and get_job return metadata only: counts, statuses, timings and truncated errors with URLs redacted, never video analysis text, feedback text or storage keys. Optional integrations are reported as configured or not (true/false), never their values. Retrying a failed job is not exposed.
Change tools
| Tool | Input | Needs |
|---|---|---|
update_user_role |
{ userId, role, expectedRole?, idempotencyKey? } |
users:roles:write (owner) |
set_user_status |
{ userId, status, reason, expectedStatus?, idempotencyKey? } |
users:sessions:write |
revoke_user_sessions |
{ userId, sessionHandle?, idempotencyKey? } |
users:sessions:write |
revoke_user_api_key |
{ userId, keyId, idempotencyKey? } |
users:credentials:write |
revoke_oauth_grant |
{ userId, grantId } |
users:credentials:write |
grant_credits |
{ userId, credits, reason, source?, expiresAt?, recurring?, idempotencyKey } |
credits:write (owner) |
revoke_credit_grant |
{ grantId, reason } |
credits:write (owner) |
add_site_optout |
{ domain, reason?, idempotencyKey? } |
optouts:write |
remove_site_optout |
{ domain, idempotencyKey? } |
optouts:write |
update_settings |
{ patch, expectedVersion?, idempotencyKey? } |
settings:write |
status is active or suspended. Suspending an account signs it out everywhere and stops its keys and OAuth grants until it is reactivated. Errors come back as tool results with structuredContent.error.code, for example role_conflict, settings_conflict, forbidden_rank, forbidden_self, idempotency_mismatch or billing_owned.
Set up an admin agent
Admin access is never granted by default. Give the agent only what its job needs, through one of these routes.
OAuth clients (Claude, ChatGPT). Connect anymd as usual. If you are an owner or admin, the consent screen offers Admin access for this connection: None (the default), Admin read-only (monitoring and reports) or Support agent. Each option lists the scopes it adds. Your role caps the result, and is applied again on every call, so a demotion removes admin access from the connection immediately. Owner-only scopes (users:roles:write, credits:write, billing:write) are never added from the consent screen; use an API key for those. Roles below admin never see the option. To change an existing connection, disconnect anymd in the client and connect again.
OAuth step-up. A client that can re-authorize does not need the consent option. When it calls a tool whose scope the grant lacks but your role allows, anymd answers 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…" listing the current scopes plus the missing one, and the client re-authorizes with them.
API key. Create a key from a preset (Dashboard → API keys, or create_api_key), capped by your role like every key:
| Job | Preset |
|---|---|
| Monitoring and reports | admin-read-only |
| Support: sign users out, suspend, revoke credentials, grant credits (owner, API key only) | support |
| Site operations: settings and opt-outs | site-ops |
claude mcp add --transport http anymd-admin https://anymd.cc/mcp \
--header "Authorization: Bearer amd_…"
With an API key, calling a tool whose scope the key lacks returns a tool error with code insufficient_scope; create a key that includes the scope. When your role does not allow the scope at all, the code is forbidden. How the consent options relate to the key presets: API keys & roles.
Troubleshooting. Ask the agent to call get_admin_access_status. It reports only on you: your role, this credential's scopes, the admin tools available now and after reconnecting, and the exact steps to fix the connection (for OAuth, also the step-up scope set). state is one of:
state |
Meaning |
|---|---|
not_eligible |
Your role has no admin access. Only an owner can change roles; nothing a client or key requests adds it. |
not_granted |
Your role allows admin access, but this connection holds no admin scopes. Reconnect with an admin option, or use a key from an admin preset. |
partial |
The connection holds some of the admin scopes your role allows. admin.missing lists the rest. |
full |
The connection holds every admin scope your role allows. |
Things to ask an admin agent:
- "How is the system doing this week? Show error rate, the slowest traces and the top errors."
- "Find the account for jane@example.com, show its keys and sessions, and sign it out everywhere."
- "Grant 500 credits to that user for the outage, reason 'October incident', for this month only, and tell me the grant id."
- "Is billing healthy? Check webhook failures and any users whose plan doesn't match their subscription."
- "Show every settings change in the last 30 days and who made it."
Things to ask your agent
- "Read https://stephango.com/saw and give me the three key ideas."
- "Before you read anything new, check my library for what we already know about this vendor."
- "Search my library for what I saved about vector databases last month."
- "Read this YouTube video's transcript and list the timestamps where pricing comes up."
- "Create a landing page from the
ads-landingtemplate for our research audience, show me the preview link, and don't publish yet."
Credits
read_url (and convert_url) costs the same credits as any other conversion; cached reads are free. search_social costs 10 credits for each page where the platform returns posts (100 on LinkedIn; all costs the sum for the platforms that returned posts), even when filtering removes them; pages where the platform returns nothing are free. Reads such as search_library, get_document and list_documents are free. See Billing & credits.
Protocol versions
One endpoint serves both protocol eras, and existing clients need no change.
- 2026-07-28 (stateless). No
initialize. Each request names its version inparams._meta["io.modelcontextprotocol/protocolVersion"]and mirrors it in theMCP-Protocol-Versionheader, withMcp-Method(andMcp-Namefortools/call; non-ASCII values as=?base64?…?=). A header that disagrees with the body gets400with error-32020; an unknown version gets400with-32022and the supported list.server/discoverdescribes the server; results carryresultType, and lists carryttlMsandcacheScope: "private". One message per request: no batches, noping. - 2025-11-25 and earlier.
initializeandpingwork as before. JSON-RPC batches (up to 20 messages; each message counts toward the rate limits) work for clients on 2025-03-26 or earlier, or that send noMCP-Protocol-Versionheader; a batch sent with2025-06-18or later gets400with-32600, since those versions dropped batching. A message that is not a JSON object gets-32600without counting toward the limits.
Events (webhooks)
MCP Events let an agent hear about changes to its own account instead of polling: anymd POSTs a small signed message to an HTTPS endpoint you run. Events are opt-in and exist only on the 2026-07-28 protocol. When the deployment offers them, server/discover lists capabilities.events; 2025-era clients see nothing new and keep working unchanged.
An event says what changed, never the content. Fetch details with the normal tools (get_document, get_video_download, list_my_feedback) under their normal scopes. A subscription grants nothing on its own: it never spends credits, never submits feedback or GitHub issues, and acting on an event goes through the same tool authorization as any other call.
There is no REST or CLI way to subscribe: events are MCP-only. In Dashboard → Event subscriptions (/dashboard/events) you can see your subscriptions with their delivery health, and delete one.
Event catalog
Every event is about your own resources, and is listed by events/list only when your credential holds its scope.
| Event | Fires when | Needs | Arguments (all optional) |
|---|---|---|---|
anymd.library.document.saved |
A document was created or its content changed (a re-read with unchanged content does not fire) | library:read |
document_ids, tags, domain, exclude_self |
anymd.library.document.deleted |
A document was deleted | library:read |
document_ids, exclude_self |
anymd.library.tags.updated |
A document's tags changed (an edit that changes nothing does not fire) | library:read |
document_ids, tags, exclude_self |
anymd.video.ready · anymd.video.failed |
A YouTube video download you started is ready, or failed for good | convert |
job_ids |
anymd.video.analysis.ready · anymd.video.analysis.failed |
The AI analysis of one of your videos is ready, or failed for good | convert |
job_ids |
anymd.feedback.status.changed |
Feedback you sent reached created (a GitHub issue exists), needs_review or failed |
feedback:write |
feedback_ids |
document_ids,job_ids,feedback_ids: only these ids (up to 50). Omit for all of yours.tags: only documents carrying every one of these tags when the event is matched to subscriptions, shortly after the change.domain: only documents from this domain or its subdomains, e.g.example.com.exclude_self(defaulttrue): skip library changes made with the same credential that subscribed, so an agent does not react to its own writes. Set it tofalseto hear them too.
Payloads are minimal: the resource id (document_id, job_id or feedback_id), resource_type, what happened (change: created/updated, status, tag_count, error_code, issue_number), version (the resource's last-change time in ms) and correlation_id (the request that made the change, when there was one). events/list returns the exact inputSchema and payloadSchema of each event.
Subscribe, refresh, unsubscribe
Only API keys and OAuth credentials can subscribe. Pick a random secret of 24 to 64 bytes, encode it as whsec_ + base64, and send it with the subscription:
{
"jsonrpc": "2.0",
"id": 1,
"method": "events/subscribe",
"params": {
"_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
"name": "anymd.library.document.saved",
"arguments": { "tags": ["research"] },
"delivery": { "mode": "webhook", "url": "https://agent.example.com/anymd-events", "secret": "whsec_…" },
"ttlMs": 3600000
}
}
The result is { id, refreshBefore, cursor: null, truncated }.
- Identity.
id(sub_…) is derived from your credential, the URL, the event name and the arguments, so subscribing again with the same four values refreshes the same subscription instead of creating a new one. The refresh result addsdeliveryStatus(active,lastDeliveryAt,lastError,failedSince). - Lifetime.
ttlMsdefaults to one hour, is clamped to between 60 seconds and 24 hours, andnullasks for the 24-hour maximum. Subscribe again beforerefreshBefore, or the subscription lapses. - Verification. The first subscription for a credential and URL pair triggers a signed POST
{"type":"verification","challenge":"…"}. Your endpoint must answer2xxwith{"challenge":"<the same value>"}. One handshake covers every event and argument set on that URL for that credential. - No replay. Events start from the moment you subscribe.
cursoris alwaysnull; if you send one, the result saystruncated: true. - Secret rotation. Refresh with a new
secret. For the next hour each message is signed with both the new and the old secret. - Limits. At most 20 live subscriptions per account (
-32013beyond that). Each subscription keeps at most 500 undelivered events; past that, new events for it are dropped until its endpoint catches up. Other subscriptions are unaffected.events/subscribeandevents/unsubscribecount toward the stricter MCP rate limit for changes.
To stop, call events/unsubscribe with the same name, arguments and delivery.url you subscribed with, from the same credential. Deleting a subscription in the dashboard works too.
Delivery
Messages follow Standard Webhooks. Each POST carries:
| Header | Value |
|---|---|
webhook-id |
The event id. Deliveries are at-least-once: deduplicate on it |
webhook-timestamp |
Unix seconds when this attempt was sent |
webhook-signature |
v1,<base64 HMAC-SHA256> over <webhook-id>.<webhook-timestamp>.<body>, keyed with the base64-decoded part of your whsec_ secret. Several space-separated signatures during a secret rotation |
X-MCP-Subscription-Id |
The subscription id |
The body of an event is { "eventId", "name", "timestamp", "data", "cursor": null }, where data is the payload above and timestamp is when the change happened. Bodies are capped at 256 KiB.
- Retries. A network error, timeout or non-
2xxanswer is retried after 1, 2, 4 and 8 minutes (5 attempts).413is never retried.410 Goneends the subscription without a termination message. Redirects are never followed and count as a failure. - Dead letters. When all attempts fail, the event is dead-lettered and the subscription is suspended: its other queued events are dead-lettered too, and nothing more is sent until your client subscribes again. Delivery then resumes with new events only.
- Termination. If the subscribing credential is revoked, expires or loses the event's scope, the account is suspended, or you delete the subscription in the dashboard, the subscription ends and your endpoint gets a signed
{"type":"terminated","error":{"code":-32012,"message":"Forbidden","data":{"reason":"…"}}}. The credential is checked before every delivery. Deleting your account removes its subscriptions without a termination message. - Retention. Delivery records are kept 7 days; dead letters and events, 30 days.
Verify every message, including the verification and termination messages, before acting on it. In Node or TypeScript:
import { createHmac, timingSafeEqual } from 'node:crypto';
// The whsec_… secret you sent in events/subscribe. Keep it out of source control.
export function verifyWebhook(secret: string, headers: Headers, body: string): boolean {
const id = headers.get('webhook-id');
const timestamp = headers.get('webhook-timestamp');
const signatures = headers.get('webhook-signature');
if (!id || !timestamp || !signatures) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; // refuse stale or replayed messages
const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${body}`).digest();
return signatures.split(' ').some((entry) => {
const [version, value] = entry.split(',');
const got = Buffer.from(value ?? '', 'base64');
return version === 'v1' && got.length === expected.length && timingSafeEqual(got, expected);
});
}
Verify against the raw request body, before parsing it. Then answer {"type":"verification"} by echoing its challenge, stop on {"type":"terminated"}, and treat anything with an eventId as an event: skip ids you have already seen, then fetch what you need with the tools.
Callback URL rules
The URL must be https, at most 2048 characters, without credentials or a #fragment, and on a public host that is not anymd itself. anymd resolves the host and requires every address to be public, both when you subscribe and again before every POST, so a host that later points at a private address is refused. A Worker cannot pin a connection to the address it checked, so a short window between check and connect remains; keep your endpoint on a host you control.
Errors
| Code | Meaning |
|---|---|
-32602 |
Invalid params: unknown argument, bad URL, non-public host, malformed secret, bad ttlMs |
-32011 |
Not found: unknown event name, or no matching subscription to unsubscribe |
-32012 |
Forbidden: your credential lacks the event's scope (data.required) |
-32013 |
Resource exhausted: 20 live subscriptions, or too many verification attempts for one host |
-32014 |
Unsupported: a delivery mode other than webhook |
-32015 |
Callback endpoint error: the endpoint could not be reached or did not echo the challenge (data.reason) |
events/* methods answer -32601 (method not found) on 2025-era protocol versions, and wherever server/discover does not list capabilities.events. These event codes follow the numbering ChatGPT implements today. The MCP Events draft may renumber them to -32023…-32027 in the same order; anymd will follow when it settles.
What this is not
- Not Claude Channels. Channels is a Claude Code feature that pushes messages into a running Claude Code session. anymd does not implement it; MCP Events deliver to a webhook endpoint you run.
- Not social monitoring. There are no recurring watches or saved searches.
search_socialis a one-shot search and emits no events.
Rate limits
MCP has its own limits, separate from the REST API, counted per user and per credential (each API key or OAuth client has its own budget): one for every request and a stricter one for tools that change data. Changes also count against one budget for the whole account, so more keys or clients do not multiply it. Over the limit you get HTTP 429 with Retry-After and JSON-RPC error -32005 (data.bucket is request or mutation; a batch gets an array with one error per request). Wait and retry.
Troubleshooting
| Symptom | Fix |
|---|---|
| A tool is missing | Your key or OAuth grant lacks its scope. Check with GET /api/v1/me or anymd whoami. |
401 invalid_api_key |
The key was revoked, expired or mistyped. Create a new one. |
402 quota_exceeded |
Free plan credits are used up for the month. |
revision_conflict from apply_page_ops |
Someone changed the page. Call get_page, then retry with the new revision. |
| Admin tools are missing | Call get_admin_access_status: it says whether your role allows admin access and how to reconnect. See Set up an admin agent. |
403 with insufficient_scope |
The OAuth grant lacks the tool's scope. Re-authorize with the scopes in the WWW-Authenticate header. |
429 / -32005 |
MCP rate limit. Wait for Retry-After seconds. |
-32020 or -32022 |
A 2026-07-28 client sent headers that don't match the body, or an unsupported version. |
Updated 2026-10-15 · Edit on GitHub