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).
| Scope | Grants |
|---|---|
intents:read | Read your intents and their criteria |
intents:write | Create, update, pause, and archive intents |
matches:read | Read introductions offered to you and their reasons |
matches:respond | Accept or decline introductions |
counterparts:read | Read the people you have matched with |
messages:read | Read your match conversations |
messages:write | Send messages in a match conversation |
meetings:read | Read proposed and confirmed meetings |
meetings:write | Propose, confirm, reschedule, and cancel meetings |
availability:read | Read your scheduling availability |
availability:write | Change your scheduling availability |
rooms:read | Read rooms you belong to and their rosters |
rooms:write | Create rooms and invite people to them |
org:read | Read your organization, members, and pipeline |
org:write | Manage organization members and permissions |
webhooks:write | Register 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
/v1/me
1 unit
any credential
Who this credential belongs to
Identity, plan, verification state, and current API allowance. Useful as a health check.
/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.
/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.
| Field | Notes |
|---|---|
role_key | string, required — your position |
industries | comma-separated, optional |
regions | comma-separated, optional |
/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
/v1/intents
1 unit
intents:read
List your intents
/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.
| Field | Notes |
|---|---|
role_key | string, required — one of the positions from /v1/taxonomy |
headline | string, required — one line describing what you seek |
summary | string — a few sentences of specifics; this is what a counterparty reads |
criteria | object — industries[], regions[], size_min, size_max, stage, dealbreakers[] |
visibility | string — "named" (default) or "anonymous" to cloak until both accept |
/v1/intents/:id
2 units
intents:write
Update, pause, or archive an intent
| Field | Notes |
|---|---|
status | "active" | "paused" | "fulfilled" | "archived" |
Matching
/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.
/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.
| Field | Notes |
|---|---|
response | "accepted" | "declined" |
/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
/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.
/v1/counterparts/:matchId
1 unit
counterparts:read
One counterpart in full
Profile, conversation, and meetings in a single response.
/v1/counterparts/:matchId/messages
2 units
messages:write
Send a message
| Field | Notes |
|---|---|
body | string, required |
Scheduling
/v1/availability
1 unit
availability:read
Your scheduling rules
Timezone, weekly hours, buffers, notice, and reschedule policy.
/v1/availability
2 units
availability:write
Replace your scheduling rules
| Field | Notes |
|---|---|
timeZone | IANA zone, e.g. "America/New_York" — never a fixed offset |
weekly | object keyed 0-6 (Sunday first), each an array of {start,end} in minutes from midnight |
minNoticeHours | number |
horizonDays | number |
rescheduleNoticeHours | number — notice others must give you |
maxReschedules | number |
/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.
/v1/counterparts/:matchId/meetings
2 units
meetings:write
Propose a meeting
| Field | Notes |
|---|---|
startISO | ISO 8601 instant, required |
durationMinutes | number |
note | string |
/v1/meetings/:id/respond
2 units
meetings:write
Confirm, decline, or cancel a meeting
| Field | Notes |
|---|---|
response | "confirmed" | "declined" | "cancelled" |
/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.
/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.
| Field | Notes |
|---|---|
from, to | epoch ms. Window is capped at 90 days. |
sources | which of provider, native, feed answered |
has_calendar | Check 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_degraded | their OAuth provider was unreachable; native events and feeds still answered |
/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.
/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.
| Field | Notes |
|---|---|
start, end | ISO 8601. Required. |
title | optional, and never published in a busy-level feed |
transparency | busy (default) blocks scheduling; free is a note to self that does not |
rrule | Supported: 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. |
/v1/calendar/events/{id}
1 unit
write:calendar
Remove an event
/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.
/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.
/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.
/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.
| Field | Notes |
|---|---|
email, name | One person, which is what a Zap usually sends |
emails | Or a list of addresses |
text | Or pasted text or CSV, as in the workspace |
message | Optional note in the email, up to 600 characters |
Rooms
/v1/rooms
1 unit
rooms:read
Rooms you belong to
/v1/rooms
2 units
rooms:write
Create a private matching pool
Requires Pro or Team. Returns a code others can join with.
| Field | Notes |
|---|---|
name | string, required |
/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.
| Field | Notes |
|---|---|
intent_id | number, required |
OAuth — apps acting for other people [NOT BUILT — design only]
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:
| Event | Fires when |
|---|---|
app.installed | A user authorised your app for the first time |
app.uninstalled | A user revoked it — clean up before your calls start failing |
app.scopes_changed | A user granted additional permissions |
These are app-only: a personal webhook cannot subscribe to them.
Sandbox [NOT BUILT]
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
- Request scopes beyond the ones your app registered — extras are dropped and reported, never silently granted.
- Escalate a user's own permissions. A token can never do more than the person could.
- Survive revocation. Users see every connected app with its permissions in plain language, and revoking kills your tokens for that person instantly — while leaving every other user's intact.
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.
| Event | Fires when |
|---|---|
introduction.offered | A reciprocal match cleared the bar and is waiting on your decision |
introduction.accepted | You accepted an introduction |
match.created | Both sides accepted — a conversation opened |
message.received | A counterpart sent you a message |
meeting.proposed | A time was proposed |
meeting.confirmed | A meeting was confirmed |
meeting.rescheduled | A meeting moved |
meeting.cancelled | A meeting was cancelled |
relationship.cooling | A counterpart has gone quiet |
relationship.archived | A counterpart was archived after 30 days |
intent.paused | An intent was paused (by you, or automatically after inactivity) |
verification.updated | Your verification tier changed |
quota.warning | API usage crossed 80% of the monthly allowance |
app.installed | A user authorised your app |
app.uninstalled | A user revoked your app |
app.scopes_changed | A user granted your app additional permissions |
programme.member_joined | Someone joined a programme you run (name, headline; the address if you invited them) |
programme.join_requested | Someone asked to join a programme you run |
programme.outcome_reported | A member reported an outcome (no name if they keep their numbers private) |
programme.event_scheduled | An event was scheduled in a programme you run |
programme.event_checkin | Someone 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.
| Operation | Units |
|---|---|
| read | 1 |
| write | 2 |
| match | 10 |
| ai | 25 |
| Plan | Included units / month | Requests / minute | Keys | Webhooks |
|---|---|---|---|---|
| free | 1,000 | 30 | 2 | 1 |
| pro | 25,000 | 120 | 5 | 5 |
| team | 100,000 | 300 | 10 | 20 |
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%.