Early access
Mint early-access codes, redeem them in one idempotent call, and reconcile who got in, all on the same pipeline the dashboard uses.
Run early access from your own backend. Invitations are the same objects the dashboard mints, so a
code taken back over the API steps its contact back exactly as it would in the app, and a redemption
here fires the same invite.redeemed webhook.
You get three patterns, and they are not alternatives:
- Push: subscribe to
invite.issued,invite.redeemed,invite.revokedandinvite.expired(webhooks). Write codes to your own allowlist and never ask us anything at signup time. - Reconcile: list and retrieve. Backfill a webhook you missed, migrate in, or audit what was handed out. This is the net under a push-only design.
- Check: redeem. One idempotent call when somebody types a code.
Scopes#
| Operation | Scope |
|---|---|
| List, retrieve, redeem, exchange | contacts:read |
| Mint, revoke, confirm | invites:write |
Redeeming and exchanging sit under contacts:read on purpose: they are what a signup flow runs on
every new account. Minting, revoking and confirming hand out or take back access to your product, so
they carry their own write scope.
Mint an invitation#
POST /v1/invitations
A personal invitation names one contact and is exactly equivalent to
PATCH /v1/contacts/{id} with stage: invited. Both go through the same action, so calling this
twice for the same person reuses the live invitation rather than minting a second. Personal invites
work on every plan. Free includes invitations for your first 100 beta users, after which minting
another is refused until the workspace moves to a paid plan; Collect, Launch and Grow are unlimited.
Codes already issued keep redeeming on every plan.
Shared codes (one code, a seat limit), open links (skip the waitlist entirely), your own
code string, and allowed_domains restriction need Launch or Grow. The plan check reads
code and allowed_domains whatever the kind is, so sending either one from a workspace without
them answers
403 plan_required even on a personal invitation.
| Parameter | Type | Applies to | Description |
|---|---|---|---|
project |
string | All, required | The prj_ handle. A pinned key may only act on its own project. |
kind |
enum | All | personal (default), shared, or open. |
contact |
string | Personal | The con_ handle to invite. A personal invitation needs contact or identity. |
identity |
string | Personal | The address to invite, when you do not have the handle. The contact must already exist on the project. |
seats |
integer | Shared, open | Seat limit, 1 to 100000. Required on shared. On open, omit it for an unlimited link. |
expires_at |
string | Shared, open | ISO 8601. Defaults to the project's early access expiry (14 days out of the box). An open link must have one, so it is required when that setting is off. |
allowed_domains |
array | Shared, open | Up to 20 domains. Only these email domains may redeem. Paid. |
code |
string | Shared, open | Bring your own code string instead of a generated one. Paid. |
metadata |
object | Shared, open | Up to 50 key-value pairs, under 4KB, stored on the invitation and echoed everywhere it appears. |
A personal invitation takes its expiry from the project's early access setting and ignores
seats, expires_at, allowed_domains, code and metadata. Send those only with
kind: shared or kind: open.
Returns 201 with the invitation object. Minting a shared code with a
seat cap and an expiry:
curl https://api.getlaunchlist.com/v1/invitations \
-H "Authorization: Bearer llk_live_8xKq3Rp9Wm2Nz7Vt6yL1cG5dH0jA4sE8uNqB2fXk" \
-H "Content-Type: application/json" \
-d '{
"project": "prj_7Qk2mN4pR8",
"kind": "shared",
"seats": 500,
"expires_at": "2026-09-01T00:00:00Z",
"allowed_domains": ["example.com"]
}'
A 409 invitation_not_issued means the mint produced no code: the contact is already sitting in
your invited stage but the code they held has been revoked or redeemed, or that stage is being
retired. A malformed bearer code answers 422 invitation_not_issued instead, with the reason in
message: a shared code with no seats, an open link that would never expire, or a code
string already in use on this project.
Redeem a code#
POST /v1/invitations/redeem
Check and redeem in one call. This is what your signup form calls when somebody types a code. code
and identity are both required. identity is whatever your project identifies people by, an email,
a phone number or a username, not email specifically.
Redeem, confirm and exchange each carry their own rate limiter on top of the per-workspace one, because this is where guessing pays. It counts four ways at once: per code, per identity, per workspace and per source IP address, so one mistyped code costs a person nothing while walking a keyspace stops early.
| Parameter | Type | Required | Description |
|---|---|---|---|
project |
string | Yes | The prj_ handle. |
code |
string | Yes | The code the person presented. |
identity |
string | Yes | The address redeeming. email is accepted as an alias. |
external_id |
string | No | Your own id for this person, unique per project. Rides the webhook. |
metadata |
object | No | Up to 50 key-value pairs, under 4KB, recorded on the redemption. |
A success returns an invitation_redemption (see the sample response). A failure returns 409,
and its code is one of expired, revoked, exhausted, already_redeemed, domain_not_allowed
or invalid. One gap in that granularity is deliberate: a real code presented by the wrong person
answers invalid, exactly like a code that never existed. Separating the two would confirm both
that the code exists and that it belongs to somebody else. An external_id already bound to a
different contact is the one failure that is not a 409: it answers 422 external_id_taken.
Validate without consuming#
Add ?dry_run=true and the answer is an invitation_check instead, with a redeemable boolean and
nothing recorded. Use it to check a form field as it is typed, then call again without the flag.
{
"object": "invitation_check",
"redeemable": true,
"invitation": { "object": "invitation", "code": "LAUNCH-ADA" }
}
Confirm access without a code#
POST /v1/invitations/confirm
Record that a person in your audience got in, with no code presented. This is you asserting it,
not a code the person typed. Requires invites:write. Takes project, identity (email is
accepted as an alias here too), and optional external_id and metadata. Idempotent on the
invitation and identity pair, and recorded as a confirmation so the dashboard never shows your
assertion as something we verified.
It confirms an invitation that already exists. Someone you never invited has nothing to confirm, so
they answer 409 not_invited. Mint them an invitation first, or move them to your invited stage
with PATCH /v1/contacts/{id}.
A success returns an invitation_confirmation. It is the redemption shape with channel added
(confirm, naming how the record was made), seats_remaining left out, and confirmed_at in place
of redeemed_at.
{
"object": "invitation_confirmation",
"id": "ivr_01k3rh8m3wef3hrf9cmhjd5qy8",
"replayed": false,
"channel": "confirm",
"invitation": {
"object": "invitation",
"id": "inv_6Rk2mN4pQx",
"code": "LAUNCH-ADA"
},
"contact": "con_4mKp9Wq2Rx",
"identity": "ada@lovelace.dev",
"external_id": "user_8821",
"metadata": null,
"confirmed_at": "2026-08-21T09:24:11+00:00"
}
Exchange a handoff reference#
POST /v1/invitations/exchange
Swap the opaque reference our claim page redirected with for the identity behind it. This is the
mechanism that lets nothing sensitive travel in a URL: what moves in the redirect is a single-use,
120-second reference that means nothing to an interceptor, and the identity only ever moves over
this authenticated call. Takes project and handoff.
You only reach this endpoint when you have opted into the verified handoff by registering a callback
origin. By default the claim page instead sends the invited person straight to your signup page with
their code in ?invite_code=, and there is nothing to exchange: you read the code and redeem it from
your own signup flow.
The answer is an invitation_handoff carrying the claims the reference stood for, including the
code itself and the external_id you had already recorded on that contact:
{
"object": "invitation_handoff",
"v": 1,
"iss": "launchlist",
"aud": "https://acme.dev/welcome",
"jti": "9fK2xR7pQm4tL8vNc3Hs6Dy1Wb5Gz8Jx0Ap2Qn7Rt4Uv6Ke",
"iat": 1787304251,
"exp": 1787304371,
"project": "prj_7Qk2mN4pR8",
"invitation": "inv_6Rk2mN4pQx",
"code": "LAUNCH-ADA",
"contact": "con_4mKp9Wq2Rx",
"identity": "ada@lovelace.dev",
"external_id": "user_8821"
}
aud is the exact origin and path the reference was bound to, and exp is when it would have died
on its own. Unknown, expired and already-consumed references all answer the same 404, so a caller
cannot learn which of their guesses was close.
List and retrieve#
GET /v1/invitations
GET /v1/invitations/{invitation}
The reconcile channel. Cursor-paginated (pagination) and filterable.
GET /v1/invitations requires a project query parameter. Invitations are listed one project
at a time, even on a key pinned to a single project, and a call without it answers
400 parameter_missing. Retrieving one invitation by its inv_ handle needs no project.
curl "https://api.getlaunchlist.com/v1/invitations?project=prj_7Qk2mN4pR8&status=pending&limit=50" \
-H "Authorization: Bearer llk_live_8xKq3Rp9Wm2Nz7Vt6yL1cG5dH0jA4sE8uNqB2fXk"
| Filter | Matches |
|---|---|
status |
pending, redeemed, expired, revoked, exhausted |
kind |
personal, shared, open |
source |
How it was created: manual, bulk, automation, api, rule, import or registration |
batch |
One batch, by its public handle |
contact |
One contact, by their con_ handle |
updated[gte] |
Changed at or after a timestamp, the sync cursor for "everything since" |
updated[lte] |
Changed at or before a timestamp |
updated[gte] is the reconcile cursor: store the newest updated_at you have seen, and ask for
everything since it the next time you sync.
Revoke#
POST /v1/invitations/{invitation}/revoke
Requires invites:write. Runs the same action the dashboard runs, so revoking a personal code from
a contact who is still sitting in your invited stage also steps them back to the waitlist. A contact
who has already moved further along stays where they are, and a bearer code belongs to nobody, so it
moves no one.
Pass an optional reason (up to 200 characters). It is stored on the invitation and echoed back as
revoked_reason, which is the difference between answering your own support question in six weeks and
guessing.
The invitation object#
| Field | Description |
|---|---|
id |
The inv_ handle. |
code |
The code string. Included on purpose, so you can write it to your own allowlist. |
kind |
personal, shared, or open. |
status |
pending, redeemed, expired, revoked, or exhausted. |
project / contact / batch |
Related handles. contact is null for shared and open codes. |
max_uses / used_count |
The seat cap and how many are spent. seats_remaining is the difference. |
allowed_domains |
Domains permitted to redeem, or null when the code is open to any domain. |
delivery_status |
The state of the invitation email, when we sent one; null otherwise. |
source |
How it came into existence. An API mint reads api. |
metadata |
Whatever you attached. |
expires_at / revoked_at |
Lifecycle timestamps, ISO 8601 or null. |
revoked_reason |
Why, in your words, when you gave one at revoke time. |
first_redeemed_at / last_redeemed_at |
When the first and most recent redemption happened. |
created_at / updated_at |
ISO 8601. |
Errors#
| Status | Code | Meaning |
|---|---|---|
400 |
parameter_missing |
project was not given on GET /v1/invitations or POST /v1/invitations/exchange. The mint, redeem and confirm writes validate it first, so a missing project there answers 422 parameter_invalid instead. |
400 |
parameter_invalid |
A limit outside 1 to 100, an unknown pagination cursor, or an updated[gte] or updated[lte] value we cannot parse. |
403 |
insufficient_scope |
The key lacks the scope the operation needs. type is permission_error. |
403 |
plan_required |
This invitation shape needs Launch or Grow. |
404 |
resource_missing |
No such invitation, project, contact or handoff, or it belongs to another tenant. |
409 |
invitation_not_issued |
The mint produced no code, because the contact holds no live invitation in your invited stage or that stage is retiring. |
409 |
redemption reasons | expired, revoked, exhausted, already_redeemed, domain_not_allowed, invalid, or not_invited on confirm. |
422 |
invitation_not_issued |
The bearer code could not be built: no seats on a shared code, an open link with no expiry, or a code already in use. |
422 |
parameter_invalid |
The body failed validation. param names the first field, and errors carries all of them. |
422 |
external_id_taken |
That external_id is already attached to a different contact on this project. |
429 |
rate_limit_exceeded |
Over the limit. Redeem, confirm and exchange carry their own limiter on top of the per-workspace one. |
Next#
- Webhooks: the four
invite.*events, so you never poll - Create a contact: a personal invite is a stage move on this same pipeline
- Authentication: scopes and pinning a key to one project
Common questions
Which scope does an integration need to redeem codes?
contacts:read. Redemption is what a signup flow calls on every new account, so pinning it behind a write scope would push every integration to a more powerful key than the job needs. Minting, revoking and confirming need invites:write.
Is it safe to call redeem twice for the same person?
Yes. Redeem is idempotent on the code and identity. A replay returns the original redemption with replayed true and a 200, rather than refusing as already redeemed, so your server crashing mid-signup can never lock someone out.
Can I validate a code without using up a seat?
Yes, add ?dry_run=true. It returns an invitation_check with a redeemable boolean and records nothing. Call again without the flag to actually let the person in.
What is the difference between redeem and confirm?
Redeem verifies a code you issued. Confirm records access for someone in your audience with no code presented, and is labeled as your assertion rather than something we checked. Use redeem whenever you have the code. Confirm needs an invitation to already exist, so somebody you never invited answers 409 not_invited.