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.

Updated ·12 min read
POST/v1/invitations

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.revoked and invite.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#

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.

Was this page helpful?
curl https://api.getlaunchlist.com/v1/invitations/redeem \
  -H "Authorization: Bearer llk_live_8xKq3Rp9Wm2Nz7Vt6yL1cG5dH0jA4sE8uNqB2fXk" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "prj_7Qk2mN4pR8",
    "code": "LAUNCH-ADA",
    "identity": "ada@lovelace.dev",
    "external_id": "user_8821"
  }'
{
  "object": "invitation_redemption",
  "id": "ivr_01k3rh8m3wef3hrf9cmhjd5qy8",
  "replayed": false,
  "contact": "con_4mKp9Wq2Rx",
  "identity": "ada@lovelace.dev",
  "external_id": "user_8821",
  "seats_remaining": 0,
  "metadata": null,
  "redeemed_at": "2026-08-21T09:24:11+00:00",
  "invitation": {
    "object": "invitation",
    "id": "inv_6Rk2mN4pQx",
    "code": "LAUNCH-ADA",
    "kind": "personal",
    "status": "redeemed",
    "max_uses": 1,
    "used_count": 1,
    "seats_remaining": 0
  }
}