Create a contact

Capture a signup through the full LaunchList pipeline (deduplication, referral credit, trust scoring, the verification email when the form asks for it, webhooks and automations) in one call.

Updated ·7 min read
POST/v1/contacts

Creates a contact and runs the full signup pipeline: deduplication, referral credit, trust scoring, the verification email, webhooks and automations. This is the same path a Launch page submission takes, so nothing is skipped.

One call returns the referral code, the live queue position and the trust verdict, so you do not need a second request to show someone their place in line.

Body parameters#

Parameter Type Required Description
project string Yes The project this signup belongs to, as a prj_ handle. A pinned key may only write to its own project.
email string Conditional The signup's email address. One of email, phone or identifier is required. See the note below for forms identified by something else.
phone string Conditional An E.164 phone number, for phone-only waitlists.
identifier string Conditional Any other identity type, paired with identifier_type.
identifier_type enum With identifier One of email, phone, username, wallet, custom.
form string No A frm_ handle. The form's own field rules still apply. Defaults to the project's oldest active waitlist form.
attributes object No Up to 50 key-value pairs stored on the contact. Keys your form does not define are kept, not dropped.
referred_by string No The referral code that sent this person. The credit lands when they verify, or at once when nobody asks them to.
external_id string No Your own identifier for this person, unique per project.

Email addresses are canonicalized before deduplication, so common Gmail misspellings resolve to the same person rather than creating two.

Subscription and verification#

A new signup is subscribed at once: joining is agreeing to hear from you, so status is subscribed and consent is signup. Whether they are also sent an email to verify their address is the form's own setting, Ask people to verify their email. The API takes no override: the form decides, the same as for a signup on your page. If you want a different answer, capture through a different form. The one addition is the project's Hold referrals until the friend verifies: when it is on, a new signup you pass a referred_by for is sent the link even if the form doesn't ask.

verification in the response says where they stand on proving their address. It is separate from status:

verification Meaning
waiting A link was made (the form asks, or referrals are held until the friend verifies). Not clicked yet.
verified They clicked it.
not_asked They have an address, but nobody asked them to verify.
no_email There is no email address to verify.

waiting means a verification email is due, not that it has gone out: a guard can still hold it back, for example when the address is known to bounce.

What comes back#

The response is a full contact object plus verification, above. A few fields answer differently than you might assume:

Field Values
deliverability null until the verdict is revealed with a credit. Otherwise deliverable, undeliverable, disposable, risky, role or unknown.
trust_tier verified, valid, unknown, risky or fake. A fresh signup we have not finished checking is honestly unknown.
position The live queue position, or null when the contact is not on the board.
status subscribed for a new signup. pending (shown as Not subscribed) is only an imported contact you were not sure about.
consent How they agreed to hear from you: confirmed, signup, imported, owner or v1. null for an imported contact you were not sure about.

referral.state is null for somebody nobody referred, and pending_verification for a brand new signup who came through a referral link, because credit has not been given yet: it waits for them to verify when the form asks them to or the project holds referrals until the friend verifies (then they are sent the link even though the form doesn't ask), and otherwise follows moments after the call. See referrals for the full list of states.

Headers#

Header Value
Authorization Bearer token. Live keys begin llk_live_.
Idempotency-Key Any unique string, up to 255 characters. A replay within 24 hours returns the original response with Idempotent-Replayed: true. Reusing a key with a different body returns 409 idempotency_key_reused.
Content-Type application/json

Errors#

Status Type Code Meaning
401 authentication_error missing_api_key No Authorization header, or a token that does not start llk_.
401 authentication_error invalid_api_key The key is unknown, revoked or expired.
403 permission_error insufficient_scope The key does not carry contacts:write.
404 not_found_error resource_missing No such project or form, or it belongs to another workspace.
409 idempotency_error idempotency_key_reused The same Idempotency-Key was reused with a different body.
409 idempotency_error idempotency_key_in_flight A request with this key is still being processed. Retry after it.
422 invalid_request_error parameter_invalid Body failed validation. Read errors for the field.
422 invalid_request_error no_capture_form No form given and the project has no active waitlist form.
422 invalid_request_error form_without_identity The target form has no identity field, so it cannot capture anyone.
422 invalid_request_error submission_rejected The spam guard refused this signup.
422 invalid_request_error external_id_taken Another contact in this project already carries that external_id.
422 invalid_request_error contact_not_created Accepted, but resolved to no contact. Check the form's identity field.
429 rate_limit_error submission_rate_limited This one signup is being submitted too fast. Honor Retry-After.
429 rate_limit_error rate_limit_exceeded Over your plan's request limit. Back off for Retry-After seconds.

Every failure uses one envelope, so a client parses a single shape for all of them. param and errors are present only when a specific field is at fault:

{
    "error": {
        "type": "invalid_request_error",
        "code": "parameter_invalid",
        "message": "One of `email`, `phone` or `identifier` is required.",
        "param": "email",
        "errors": {
            "email": ["One of `email`, `phone` or `identifier` is required."]
        },
        "request_id": "req_01jq8zk3m7xn2vb4rd5eempt9h"
    }
}

Worth knowing#

  • Capture never fails for billing reasons. If the workspace is over its plan's contact limit, the signup is still stored. The response returns locked: true and withholds the identity until the plan is upgraded. Nobody's signups are ever refused or deleted over a plan cap.
  • Positions come from Redis, not a table scan, so this call stays fast at a million contacts.
  • Referral credit follows verification. Passing referred_by records who sent this person right away, but the referrer's count moves when the new signup verifies. On a form that does not ask people to verify there is nothing to wait for, so the credit lands at creation, unless the project holds referrals until the friend verifies: then they are sent the link and the credit waits for the click.
  • A stage with the invited role mints an invitation. That is not this endpoint, but it is worth knowing before you move somebody with update.
  • Every response carries a Request-Id. Quote it in support and we can find the exact call.

Common questions

What happens if I submit an email address that is already on the waitlist?

You get 200 with the existing contact rather than an error, so a form that double-fires never creates a duplicate and never shows the visitor a failure. A genuinely new signup returns 201.

Is POST /v1/contacts safe to retry?

Yes. Send an Idempotency-Key header and a replay within 24 hours returns the original response instead of creating a second contact. Only a successful response is stored, so a request that failed is still yours to retry with the same key.

Why is deliverability null on a contact I just created?

Because that contact has not been checked yet. Deliverability is filled in once the contact has been checked with a credit. Until then the field is null on every read path, including this one.

Does a signup fail if the workspace is over its plan's contact limit?

No. Capture is unconditional, so the signup is always stored. The response returns locked true and withholds the identity until the plan is upgraded.

Was this page helpful?
curl https://api.getlaunchlist.com/v1/contacts \
  -H "Authorization: Bearer llk_live_8xKq3Rp9Wm2Nz7Vt6yL1cG5dH0jA4sE8uNqB2fXk" \
  -H "Idempotency-Key: signup_8f3a2b" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "prj_7Qk2mN4pR8",
    "email": "ada@lovelace.dev",
    "referred_by": "SARAH42"
  }'
{
  "object": "contact",
  "id": "con_4mKp9Wq2Rx",
  "livemode": true,
  "project": "prj_7Qk2mN4pR8",
  "identifier": "ada@lovelace.dev",
  "identifier_type": "email",
  "email": "ada@lovelace.dev",
  "external_id": null,
  "attributes": {},
  "status": "subscribed",
  "stage": "waitlist",
  "deliverability": null,
  "trust_tier": "unknown",
  "position": 1284,
  "referral": {
    "code": "ADA7X2",
    "url": "https://acme.launchlist.page?ref=ADA7X2",
    "count": 0,
    "referred_by": "con_2Bn8Vd5Lk",
    "state": "pending_verification"
  },
  "unsubscribed": false,
  "locked": false,
  "verified_at": null,
  "opt_in_confirmed_at": null,
  "consent": "signup",
  "consented_at": "2026-08-01T09:24:11+00:00",
  "unsubscribed_by": null,
  "unsubscribed_at": null,
  "created_at": "2026-08-01T09:24:11+00:00",
  "url": "/v1/contacts/con_4mKp9Wq2Rx",
  "verification": "waiting"
}