API reference

v1 · scoped keys and webhooks are live

What is actually live, as of this build

Live: API keys with scopes, hashed and revocable, over your own data — profile, intents, introductions, meetings. HMAC-signed webhooks with a timestamp and a replay window.

Not built yet: OAuth apps (third-party apps acting on another user's behalf), the sandbox environment, and the app directory. Those sections below describe the intended design, not something you can call today. They are marked.

OAuth is not a larger API key. It is a consent system, a token exchange, a revocation model, and a review process for the apps themselves — shipping the flow without the review is how a phishing app gets a real authorize screen on this domain. It waits until it can be done properly.

There is deliberately no scope that reads another user, no directory endpoint, and no scope that sends messages. An API that could do those would be a cold-outreach tool with an access token attached, which is the one thing this platform is not.

Maelstrom in your workflow. State intents from your CRM, act on introductions from your automation platform, and push matches and meetings into whatever you already use. Every endpoint is scoped, metered, and documented from the same declaration that implements it — so the docs can't drift from the code.

Getting started

Create a key in the app under Developer API. Keys look like mls_live_a1b2c3_… and are shown once — we store a hash, so we can't recover one for you.

curl https://api.maelstrom.it.com/v1/me \
  -H "Authorization: Bearer mls_live_a1b2c3_…"

Use mls_test_… keys while building. Every response carries X-Maelstrom-Units-Used and X-Maelstrom-Units-Remaining, so your integration can see its own consumption without a separate call.

Scopes

Keys carry only what you grant. Three presets cover most cases: Read only (8), Automation (12), Full access (16).

ScopeGrants
intents:readRead your intents and their criteria
intents:writeCreate, update, pause, and archive intents
matches:readRead introductions offered to you and their reasons
matches:respondAccept or decline introductions
counterparts:readRead the people you have matched with
messages:readRead your match conversations
messages:writeSend messages in a match conversation
meetings:readRead proposed and confirmed meetings
meetings:writePropose, confirm, reschedule, and cancel meetings
availability:readRead your scheduling availability
availability:writeChange your scheduling availability
rooms:readRead rooms you belong to and their rosters
rooms:writeCreate rooms and invite people to them
org:readRead your organization, members, and pipeline
org:writeManage organization members and permissions
webhooks:writeRegister endpoints to receive events

Errors

Errors are JSON with a stable code, a human message, and enough context to act on. 403 insufficient_scope names the scope you're missing; 402 quota_exhausted tells you when the allowance resets and confirms nothing was billed.

Discovery

GET /v1/me 1 unit any credential

Who this credential belongs to

Identity, plan, verification state, and current API allowance. Useful as a health check.

GET /v1/taxonomy 1 unit any credential

Every position and its complement

The positions matching runs across (148 today). Every position has exactly one complement; matching only ever happens between a pair. Use this to build a position picker.

GET /v1/discover 10 units any credential

How many counterparts are seeking what you offer

Answers "is there anyone here for me?" without revealing anyone. Counts are rounded into bands and groups smaller than five are suppressed, so no individual can be identified. Costs match-tier units because it runs the same filtering as a real search.

FieldNotes
role_keystring, required — your position
industriescomma-separated, optional
regionscomma-separated, optional
GET /v1/usage 1 unit any credential

Your API consumption this month

Units used and remaining, per-endpoint breakdown, and whether usage would halt right now.

Intents

GET /v1/intents 1 unit intents:read

List your intents

POST /v1/intents 2 units intents:write

State a new intent

The atomic unit of the platform: what you are, what you seek, and under what conditions. Matching begins on the next run.

FieldNotes
role_keystring, required — one of the positions from /v1/taxonomy
headlinestring, required — one line describing what you seek
summarystring — a few sentences of specifics; this is what a counterparty reads
criteriaobject — industries[], regions[], size_min, size_max, stage, dealbreakers[]
visibilitystring — "named" (default) or "anonymous" to cloak until both accept
PATCH /v1/intents/:id 2 units intents:write

Update, pause, or archive an intent

FieldNotes
status"active" | "paused" | "fulfilled" | "archived"

Matching

GET /v1/introductions 1 unit matches:read

Introductions waiting on your decision

Each carries the reasons it was made, in your own stated criteria. Anonymous counterparties have no identity until both sides accept.

POST /v1/introductions/:id/respond 2 units matches:respond

Accept or decline an introduction

Accepting requires identity verification, because it opens a conversation with a real person. Declining never does, and the other party is not told.

FieldNotes
response"accepted" | "declined"
POST /v1/matching/run 10 units intents:write

Run matching now instead of waiting for the nightly pass

Costs more units than a read, because it is genuinely expensive. Rate-limited accordingly.

Counterparts

GET /v1/counterparts 1 unit counterparts:read

Everyone you have matched with

Includes relationship warmth, the last message, the next meeting, and the intent pairing that produced the match. Add ?archived=1 to include archived relationships.

GET /v1/counterparts/:matchId 1 unit counterparts:read

One counterpart in full

Profile, conversation, and meetings in a single response.

POST /v1/counterparts/:matchId/messages 2 units messages:write

Send a message

FieldNotes
bodystring, required

Scheduling

GET /v1/availability 1 unit availability:read

Your scheduling rules

Timezone, weekly hours, buffers, notice, and reschedule policy.

PUT /v1/availability 2 units availability:write

Replace your scheduling rules

FieldNotes
timeZoneIANA zone, e.g. "America/New_York" — never a fixed offset
weeklyobject keyed 0-6 (Sunday first), each an array of {start,end} in minutes from midnight
minNoticeHoursnumber
horizonDaysnumber
rescheduleNoticeHoursnumber — notice others must give you
maxReschedulesnumber
GET /v1/counterparts/:matchId/slots 10 units meetings:read

Times that are free for both of you

Every slot carries BOTH parties' clock readings and warnings for different calendar days, unsociable hours, and partial-hour timezone offsets. Slots are held briefly once returned.

POST /v1/counterparts/:matchId/meetings 2 units meetings:write

Propose a meeting

FieldNotes
startISOISO 8601 instant, required
durationMinutesnumber
notestring
POST /v1/meetings/:id/respond 2 units meetings:write

Confirm, decline, or cancel a meeting

FieldNotes
response"confirmed" | "declined" | "cancelled"
POST /v1/meetings/:id/reschedule 2 units meetings:write

Move a meeting

Subject to the other party's notice period and reschedule limit. Resets confirmation.

Calendar [LIVE]

Three ways to give Maelstrom availability, and none is required: connect Google, subscribe to any published ICS URL, or state busy times directly. Everything below acts on your own calendar only — there is no scope that reads anyone else's.

Outlook and Microsoft 365 users: there is no OAuth connection, and there will not be — Graph offers no free/busy-only permission. Use POST /v1/calendar/feeds with the URL from Outlook's own Publish a calendar dialog, choosing Availability only. You grant us nothing, and you can verify that in your own settings. The trade: a published feed is not live and can lag a few hours. Connect Google if you need to-the-minute accuracy.

GET /v1/calendar/availability 2 units read:calendar

Computed FREE intervals — the endpoint a workflow wants

Returns free time, not busy blocks, already merged across every source and windowed to your working hours in your own timezone. Reimplementing that from raw busy blocks means redoing the interval subtraction and the DST handling, and the DST cases are where it goes wrong.

FieldNotes
from, toepoch ms. Window is capped at 90 days.
sourceswhich of provider, native, feed answered
has_calendarCheck this. false means the account has no calendar source at all, so the free slots are stated working hours only. "No slots because they are busy" and "no slots because nothing is connected" are different answers, and a workflow that cannot tell them apart will retry forever against someone who never set anything up.
provider_degradedtheir OAuth provider was unreachable; native events and feeds still answered
GET /v1/calendar/events 1 unit read:calendar

Your own stated events

Recurring events are returned as a single row carrying an rrule, not pre-expanded.

POST /v1/calendar/events 2 units write:calendar

Block out time

Events created through the API are tagged source: "api", so a misbehaving integration's output can be told apart from what you entered yourself and cleaned up.

FieldNotes
start, endISO 8601. Required.
titleoptional, and never published in a busy-level feed
transparencybusy (default) blocks scheduling; free is a note to self that does not
rruleSupported: FREQ=DAILY|WEEKLY|MONTHLY|YEARLY with INTERVAL, COUNT, UNTIL, and BYDAY on weekly rules. Anything else is refused at write time rather than stored and silently misread later.
DELETE /v1/calendar/events/{id} 1 unit write:calendar

Remove an event

GET /v1/calendar/feeds 1 unit read:calendar

Subscribed calendar feeds and their health

Returns each feed's label, last fetch, last status and whether it is stale. The URL is never returned — a published feed URL is a bearer credential for that calendar, and we have no reason to hand it back.

Programmes [LIVE]

For organisers: an accelerator, a fund's portfolio, an adviser's clients, a community, an event. These act on programmes you run (or your firm runs, if you are one of its admins), with what your programme workspace already shows. They are what a Zapier or Make step calls: invite everyone who fills in a form, or who joins your Circle community.

GET /v1/programmes 1 unit read:programmes

Programmes you run

Each with its id, name, kind, join rule, member count and how many are waiting to be admitted.

GET /v1/programmes/:id/members 1 unit read:programmes

Who is in a programme

Name, headline and when they joined. An email address only for people you invited, since you typed it.

POST /v1/programmes/:id/invites 2 units write:programmes

Invite people

Sends each address one email from you with a link that admits them. Same limits as the workspace: 500 a call, 2,000 a day. You are not told which addresses already have accounts.

FieldNotes
email, nameOne person, which is what a Zap usually sends
emailsOr a list of addresses
textOr pasted text or CSV, as in the workspace
messageOptional note in the email, up to 600 characters

Rooms

GET /v1/rooms 1 unit rooms:read

Rooms you belong to

POST /v1/rooms 2 units rooms:write

Create a private matching pool

Requires Pro or Team. Returns a code others can join with.

FieldNotes
namestring, required
POST /v1/rooms/:id/search 10 units rooms:write

Run a room-exclusive search

Same complement rule and scoring as the open network; the room only narrows who is eligible.

FieldNotes
intent_idnumber, required

OAuth — apps acting for other people [NOT BUILT — design only]

None of this section is callable today. See the status note at the top of this page. Use an API key instead; it covers your own data.

An API key is you acting on your own data. If you're building something other Maelstrom users install, each of them must consent individually and be able to withdraw that consent without affecting anyone else. That's OAuth, and it's what a marketplace app needs.

Register

Create an app in the dashboard under Developer API. You get a client_id and a client_secret (shown once). Apps start in development — usable by you alone — and go public after review, so a directory can't fill with impersonations.

1. Send the user to authorize

GET /oauth/authorize
  ?client_id=mapp_…
  &redirect_uri=https://yourapp.com/callback
  &scope=counterparts:read%20matches:read
  &state=<random, checked on return>
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256

PKCE is mandatory, including for confidential clients. It costs you nothing and removes an entire class of interception attack. code_challenge_method must be S256.

Redirect URIs are matched exactly — no prefixes, no wildcards. Open redirects are how authorization codes get stolen.

2. Exchange the code

POST /v1/oauth/token
{
  "grant_type": "authorization_code",
  "code": "…",
  "client_id": "mapp_…",
  "client_secret": "msec_…",
  "redirect_uri": "https://yourapp.com/callback",
  "code_verifier": "…"
}

Returns an access token (mat_…, one hour) and a refresh token (mrt_…, 90 days). Use the access token exactly like an API key.

Codes are single-use. Presenting one twice means it leaked, so we revoke every token on that grant rather than guessing which use was legitimate. Handle code_already_used by starting the flow again.

3. Refresh

POST /v1/oauth/token
{ "grant_type": "refresh_token", "refresh_token": "mrt_…",
  "client_id": "mapp_…", "client_secret": "msec_…" }

Refresh tokens rotate. Each refresh invalidates the previous pair — store the new one or your next call fails.

Asking for more permissions later

Send the user through the same flow with the additional scope and merge: true. The consent screen then leads with what is new rather than re-listing everything they already approved — people stop reading a list they've seen before, which is exactly when a widened permission slips through unnoticed.

The authorize description returns incremental, new_scopes, existing_scopes, and dropped_scopes, so you can render this properly. Without merge, a new request replaces the grant — which is how an app narrows its own access.

Lifecycle events

Subscribe your app's webhook to these and stop discovering revocations through a 401:

EventFires when
app.installedA user authorised your app for the first time
app.uninstalledA user revoked it — clean up before your calls start failing
app.scopes_changedA user granted additional permissions

These are app-only: a personal webhook cannot subscribe to them.

Sandbox [NOT BUILT]

There is no sandbox environment yet. Keys issued today are live keys against live data.

Build against a synthetic population instead of your own live account. Create one in the dashboard and you get ten people arranged in complementary pairs — a founder and an investor, a brand and a manufacturer, a seller and an acquirer — with real matches, conversations, and booked meetings between them.

curl https://api.maelstrom.it.com/v1/counterparts   -H "Authorization: Bearer mls_test_…"   -H "X-Maelstrom-Sandbox-User: 9003"

A test key acts as one of your sandbox people; omit the header and you act as the first. A test key can never reach a live account, and a live key never reaches the sandbox — the two are quarantined in both directions. Sandbox personas are excluded from real matching and never receive email.

The data is deliberately imperfect: one thread is left unanswered and not every match has a meeting, because a sandbox where everything is tidy teaches your app nothing about the cases it will actually meet.

One-click install links

Generate a signed link to put behind an "Add to Maelstrom" button on your own site. The signature covers the client id, the scopes, and the redirect — so nobody can edit the link to request more than your app advertises. A link that fails verification shows the user a warning instead of a consent screen.

Replaying events

Fire any subscribed event at your own endpoint on demand instead of waiting for a real user to do something — which is the actual blocker, since several events only occur when someone acts.

Replayed deliveries carry "replay": true in the payload. That's deliberate and loud: an app that treats a replay as real and emails a customer is a bug you want failing in testing, not quietly in production. Replays always fire on behalf of a sandbox user — never a real one, because a synthetic event carrying a real person's id would put fabricated data into your record of them.

Before you submit

Your dashboard runs the same checklist a reviewer does — description, website, tagline, privacy policy, HTTPS-only redirects, and whether the number of permissions you request is defensible. Nothing on it is secret, so you may as well clear it before submitting rather than after. Submission is refused while a required item is unmet, and the refusal shows you the list.

Changing your permissions later

Narrowing what you request applies immediately — nobody needs protecting from an app asking for less. Widening beyond what review approved sends the app back to review and pulls it from the directory until it's approved again, because an app with more power than users saw at install is materially a different app.

Existing installs are never retroactively widened. A grant holds exactly what that user approved; changing your app's requested scopes cannot reach into it. Send those users through authorization again with merge: true.

Delivery log

Every webhook delivery, with attempt count, the response we got back, latency, and whether it was a replay. Redelivering resets the existing entry rather than duplicating it, so the log stays readable.

Alerts

We email you when your app's error rate is high in absolute terms, or is far worse than other apps on the platform, or your webhook endpoint starts failing, or you're being throttled repeatedly. One alert per issue per week at most. A broken integration loses installs quietly, so waiting for you to open a dashboard is the wrong default.

Analytics

Your dashboard shows installs and uninstalls over time, calls and units by endpoint, errors broken out by status with what each one means, and a comparison against other apps on the platform — a 12% error rate means something different depending on whether the median is 4% or 11%. Where too few apps are comparable we say so rather than publishing a median nobody should act on. — a 403 (a scope you never requested) and a 429 (you're being throttled) call for completely different fixes. You also get specific suggestions rather than raw numbers to interpret.

What you can't do

Webhooks

Rather than polling, register an HTTPS endpoint and we'll push events to it. Each delivery carries x-maelstrom-timestamp (Unix seconds) and x-maelstrom-signature: the hex HMAC-SHA256 of ${timestamp}.${rawBody} with your endpoint's signing secret. Verify it with a constant-time comparison and reject anything stale.

EventFires when
introduction.offeredA reciprocal match cleared the bar and is waiting on your decision
introduction.acceptedYou accepted an introduction
match.createdBoth sides accepted — a conversation opened
message.receivedA counterpart sent you a message
meeting.proposedA time was proposed
meeting.confirmedA meeting was confirmed
meeting.rescheduledA meeting moved
meeting.cancelledA meeting was cancelled
relationship.coolingA counterpart has gone quiet
relationship.archivedA counterpart was archived after 30 days
intent.pausedAn intent was paused (by you, or automatically after inactivity)
verification.updatedYour verification tier changed
quota.warningAPI usage crossed 80% of the monthly allowance
app.installedA user authorised your app
app.uninstalledA user revoked your app
app.scopes_changedA user granted your app additional permissions
programme.member_joinedSomeone joined a programme you run (name, headline; the address if you invited them)
programme.join_requestedSomeone asked to join a programme you run
programme.outcome_reportedA member reported an outcome (no name if they keep their numbers private)
programme.event_scheduledAn event was scheduled in a programme you run
programme.event_checkinSomeone checked in at one of your events

Failed deliveries retry with backoff; an endpoint that fails 20 times in a row is disabled and you're told why.

Limits and pricing

Usage is metered in units, not calls, because a matching run costs far more to serve than reading a profile. Pretending otherwise would either overcharge cheap calls or subsidise expensive ones.

OperationUnits
read1
write2
match10
ai25
PlanIncluded units / monthRequests / minuteKeysWebhooks
free1,000 3021
pro25,000 12055
team100,000 3001020
You cannot be billed by surprise. When the included allowance runs out, requests stop with 402 quota_exhausted unless you have both added a payment method and explicitly switched on metered usage. Overage is $0.50 per 1,000 units, and you can set your own monthly ceiling. We email once at 80%.