# 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. Source: https://getlaunchlist.com/help/docs/api/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](/help/docs/plugins/webhook)). Write codes to your own allowlist and never ask us anything at signup time. - **Reconcile**: [list](#list-and-retrieve) 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](#redeem-a-code). 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](#the-invitation-object). Minting a `shared` code with a seat cap and an expiry: ```bash 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. > [!WARNING] A personal invitation moves the contact into your invited stage > That is the point, and it applies to somebody who is already further along too. Minting for a > contact who has redeemed and joined moves them **back** to invited and issues a fresh code. Read > their `stage` first if that is not what you want. ## 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. > [!NOTE] Redeem records access, it does not register your user > A redemption is our record that **you granted this identity access**, on a real seat. It is not proof > the person finished signing up in your product, because that happens on your side. If your account > creation can fail after you redeem, redeem first, create the account, and **revoke** the redemption if > creation fails, so the seat is handed back: > > ``` > redeem on LaunchList → create the account in your app → revoke if that fails > ``` > > This is the pragmatic pattern, not a single atomic transaction across both systems. Redeeming first > claims the seat safely (it is idempotent); the reverse order can lose the last seat to someone else in > the moment between your account creation and your redeem call. > [!WARNING] Redeem is idempotent, and that is the whole point > Replaying the same `code` and `identity` returns the **original** redemption with `replayed: true` > and a `200`. It does not answer "already redeemed". Without that, your server crashing between our > commit and your own user insert would lock that person out permanently. Send the same call again > and it just works. 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. ```json { "object": "invitation_check", "redeemable": true, "invitation": { "object": "invitation", "code": "LAUNCH-ADA" } } ``` > [!WARNING] Do not build on `dry_run` alone > An integration that only ever calls with `dry_run=true` gets working validation and an Invites tab > that reads zero redemptions forever. The dry run checks; the real call lets the person in. ## 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`. ```json { "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" } ``` > [!WARNING] We authenticate your project, not your user > A `200` here is not an authentication result. Establish who the person is yourself (a signed-in > session, a verified address, an OTP, trusted SSO) **before** calling this. It must never, by > itself, create or sign in an account. If you have the code, use `redeem`: it is strictly better > evidence. ## 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: ```json { "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](/help/docs/api/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`. ```bash 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. > [!WARNING] Revoke does not undo a redemption > A code that has already been redeemed cannot be revoked. A personal code is fully spent the > moment it is used, so revoking it after the fact does nothing and still answers `200` with the > invitation unchanged. **Read `status` on the response**: `revoked` means it worked, `redeemed` > means the code was already spent and nothing changed. Reversing a redemption is a different > operation and is not on the API yet, so undo it from the dashboard. Revoking a `shared` or `open` > code with seats left does work: it stops every further redemption without touching the ones > already recorded. ## 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](/help/docs/plugins/webhook): the four `invite.*` events, so you never poll - [Create a contact](/help/docs/api/contacts-create): a personal invite is a stage move on this same pipeline - [Authentication](/help/docs/api/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.