Alps

Syncing contacts into Alps automatically

9 min readInvalid Date11 views

Overview

If people already sign up, log in, or upgrade somewhere in your product, you shouldn't have to re-type their details into Alps by hand. This API lets your own backend create and update Alps contacts automatically, the moment something happens on your side - a signup, a plan change, a profile edit.

Use it to:

  • Create a contact in Alps the instant someone signs up, so your team can see and support them without anyone adding them manually.

  • Keep a contact's details (name, email, phone, company, and more) up to date as your users update their own profile.

  • Track where someone is in their journey with you using lifecycle stages - a label like "Lead," "Qualified Lead," or "Customer" that moves with the contact automatically as you update it.

Before you begin

You'll need an API key.

  1. In Alps, go to Settings → Apps → API Keys → New Key.

  2. Give it a name that identifies where it'll be used (for example, "Production backend").

  3. Copy the key immediately - it's shown to you exactly once. After that, Alps only ever shows the last 4 characters, so if you lose it you'll need to create a new one.

  4. Store it in your backend's environment variables or secrets manager. Treat it like a database password: never put it in client-side JavaScript, a mobile app binary, or anywhere a user could open dev tools and read it.

Every request in this guide authenticates with that key:

Authorization: Bearer alps_sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Rotating a key: creating a new one keeps the old key working for 7 more days, so you can roll credentials over without any downtime. Revoking a key (if one ever leaks) takes effect immediately.

How it works

This is an upsert API: one endpoint, and Alps figures out whether to create a new contact or update an existing one. You tell Alps how to recognize a person - by their email, or by your own internal user ID - and every call after the first one for that same person updates the same contact instead of creating a duplicate.

A few things worth knowing before you integrate:

  • Whatever you send wins. If you sync a contact's company field, that overwrites whatever was there before. This is different from how a support agent editing a contact by hand works in the Alps dashboard (which only fills in blanks so it never overwrites something a teammate typed in) - this API is meant to be the source of truth for the fields you send.

  • Lifecycle stages are yours to define. Alps ships with sensible defaults, but any workspace admin can rename, reorder, recolor, or add stages under Settings → Contact → Lifecycle Stages. You sync a stage by its ID, not its display label - see "Finding your lifecycle stage IDs" below.

  • Possible duplicates get flagged, never auto-merged. If a contact you create looks like it might already exist elsewhere in your workspace - same full name, or the same email/phone already on a different contact - Alps flags it with a "Possible duplicate" banner on that contact's page in the dashboard. Nothing is combined automatically; a teammate reviews it and merges or dismisses it with one click.

  • What your team sees, once synced: the contact shows up in the Contacts list with a colored stage badge, and clicking it opens a quick-view panel with everything you've synced - profile details, company, address, and any custom data - without leaving the list. Every field in that view is read-only until someone deliberately clicks Edit, so nothing gets changed by accident while browsing.

Sync one contact

POST /contacts

This is the endpoint you call on a signup, a profile update, or a lifecycle-stage change.

Request

POST https://api.tryalps.com/api/v1/contacts

Authorization: Bearer alps_sk_live_...

Content-Type: application/json

Idempotency-Key: signup-usr_9f21c8

{

"idProperty": "email",

"email": "jane@example.com",

"firstName": "Jane",

"lastName": "Doe",

"lifecycleStage": "lead",

"customProperties": {

"plan": "trial",

"signupSource": "landing-page"

}

}

Fields

Field

Type

Notes

idProperty

"email" or "externalId"

Which field identifies this person. Defaults to "email".

email

string

Required when idProperty is "email".

externalId

string

Your own internal user ID. Required when idProperty is "externalId" - use this if a person's email could change, or you'd rather match on your own ID.

firstName, lastName, phone, phoneCountryCode, address, address2, website, gender, company, jobTitle, jobRole, websiteDomain, city, state, country, language, employeeCount, note

various

Optional standard fields - send whatever you have.

avatarUrl

string (URL)

Profile photo. Must be a URL Alps can load directly - Alps displays it, it doesn't fetch or re-host the image itself.

ownerId

string

Assigns the contact to a specific teammate (a workspace member's ID). Leave it out to leave ownership unchanged.

lifecycleStage

string

The stage ID, not the label - for example "lead", not "Lead". Must match a stage that exists in your workspace or the request is rejected.

customProperties

object

Any key-value data specific to your business - for example {"plan": "pro", "mrr": 49}. Merges into the contact's existing custom data: keys you send overwrite matching keys, everything else is left alone.

Response - 201 for a newly created contact, 200 for an update to an existing one:

{

"status": "success",

"data": {

"data": {

"id": "8f2a1c...",

"email": "jane@example.com",

"firstName": "Jane",

"lastName": "Doe",

"lifecycleStage": "lead",

"customAttributes": { "plan": "trial", "signupSource": "landing-page" },

"isNew": true,

"createdAt": "...",

"updatedAt": "..."

}

}

}

If the new contact looked like a possible duplicate of someone already in your workspace, the response also includes possibleDuplicateOf (the other contact's ID) and duplicateReason ("name_match" or "identity_conflict") - informational only, as described above.

Sync many contacts at once

POST /contacts/batch

Useful for an initial backfill of your existing user base. For live, one-at-a-time events (signups, upgrades), use the single endpoint above instead.

Request

POST https://api.tryalps.com/api/v1/contacts/batch

Authorization: Bearer alps_sk_live_...

Content-Type: application/json

{

"inputs": [

{ "idProperty": "email", "email": "a@example.com", "lifecycleStage": "lead" },

{ "idProperty": "email", "email": "b@example.com", "lifecycleStage": "customer" }

]

}

Up to 100 contacts per request, same fields as the single endpoint, one object per contact.

Response - always 200. One bad entry never blocks the rest - check summary and each item's own status:

{

"status": "success",

"data": {

"data": {

"results": [

{ "index": 0, "status": "success", "isNew": true, "contact": { "...": "..." } },

{ "index": 1, "status": "error", "message": "Unknown lifecycleStage "bogus"" }

],

"summary": { "total": 2, "succeeded": 1, "failed": 1 }

}

}

}

Finding your lifecycle stage IDs

Open Settings → Contact → Lifecycle Stages in Alps - the ID column is what you send in lifecycleStage, not the display label shown next to it. There's currently no way to look this list up through the API itself, so keep it handy on your side once you've confirmed it. A 400 Unknown lifecycleStage error almost always means a typo, or that the stage was renamed or removed since you last checked.

Getting notified back (webhooks)

Alps can call your own server when something happens, so you don't have to poll:

Event

Fires When

contact.created

A new contact is created - from this API, the dashboard, an import, or someone messaging in through

contact.lifecycle_stage_changed

A contact's stage changes, from either this API or a teammate editing it by hand. The payload includes both the old and new stage.

Self-serve webhook setup is on its way - for now, contact your Alps account team to configure a subscription for your workspace. Once it's set up, every delivery includes an X-Alps-Signature header - an HMAC-SHA256 signature of the raw request body, signed with the secret you were given when the subscription was created:

const crypto = require('crypto');

function isValidAlpsWebhook(rawBody, signatureHeader, secret) {

const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

return crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected));

}

Payload shape:

{

"event": "contact.lifecycle_stage_changed",

"data": {

"contact": { "id": "...", "email": "jane@example.com", "lifecycleStage": "customer" },

"previousLifecycleStage": "lead",

"lifecycleStage": "customer"

},

"timestamp": "2026-08-19T12:00:00.000Z"

}

Deliveries aren't retried on failure today, so respond 2xx quickly and do any slow work asynchronously on your end.

Best practices

  • Use an idempotency key for anything you might retry. Pass Idempotency-Key: <your-own-unique-value> and a retried request with the same key replays the original response instead of being reprocessed - safe to retry blindly after a timeout. Generate one key per real-world event (one per signup, one per upgrade), not a new one per HTTP attempt.

  • Only retry on 5xx or network errors. A 4xx means the request itself needs fixing - retrying it unchanged will fail the same way every time. Use exponential backoff for retries.

  • Rate limits: 100 requests per 10 seconds, per API key. A batch call counts as a single request no matter how many contacts are inside it.

  • You can't accidentally create a duplicate through this API. Calling it twice for the same email (or externalId) always updates the same contact.

Troubleshooting

Status

Meaning

What to Check

400

Bad request

A required field is missing, idProperty is invalid, lifecycleStage doesn't match a real stage ID, or (batch) you sent more than 100 items or an empty list.

401

Missing, invalid, or revoked API key

Confirm the key is still active under Settings → Apps → API Keys, and that you're sending Authorization: Bearer alps_sk_live_... exactly.

403

Key doesn't have permission

Ask whoever manages your Alps workspace to check the key's scopes.

409

A request with this same Idempotency-Key is already being processed

Wait a moment and retry - this resolves itself once the original request finishes.

429

Rate limit exceeded

Back off and retry with exponential delay.


A complete example (Node.js / Express)

lib/alps.js - everything else in your app should import from here, nothing else should call fetch() directly against Alps.

const ALPS_API_KEY = process.env.ALPS_API_KEY;

const ALPS_BASE_URL = 'https://api.tryalps.com/api/v1';

async function syncContact(fields, { idempotencyKey, maxRetries = 3 } = {}) {

let attempt = 0;

while (true) {

attempt++;

let res;

try {

res = await fetch(${ALPS_BASE_URL}/contacts, {

method: 'POST',

headers: {

Authorization: Bearer ${ALPS_API_KEY},

'Content-Type': 'application/json',

...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),

},

body: JSON.stringify(fields),

});

} catch (networkErr) {

if (attempt > maxRetries) throw networkErr;

await backoff(attempt);

continue;

}

if (res.ok) return (await res.json()).data.data;

if (res.status >= 400 && res.status < 500 && res.status !== 429) {

const body = await res.json().catch(() => ({}));

throw new Error(Alps rejected the request (${res.status}): ${body.message || 'unknown error'});

}

if (attempt > maxRetries) throw new Error(Alps sync failed after ${maxRetries} retries (${res.status}));

await backoff(attempt);

}

}

function backoff(attempt) {

const ms = Math.min(1000 2 * attempt, 30000) + Math.random() * 500;

return new Promise((resolve) => setTimeout(resolve, ms));

}

module.exports = { syncContact };

routes/signup.js - called right after your own account creation

const { syncContact } = require('../lib/alps');

app.post('/api/signup', async (req, res) => {

const user = await createUserInOurDatabase(req.body);

syncContact(

{

idProperty: 'externalId',

externalId: user.id,

email: user.email,

firstName: user.firstName,

lastName: user.lastName,

lifecycleStage: 'lead',

customProperties: { signupSource: req.body.source || 'direct' },

},

{ idempotencyKey: signup-${user.id} }

).catch((err) => console.error('[Alps sync] signup failed:', err.message));

res.status(201).json({ user });

});

routes/billing.js - wherever "became a paying customer" happens in your app

const { syncContact } = require('../lib/alps');

async function onSubscriptionActivated(user, subscription) {

await syncContact(

{

idProperty: 'externalId',

externalId: user.id,

lifecycleStage: 'customer',

customProperties: { plan: subscription.plan, mrr: subscription.amount },

},

{ idempotencyKey: sub-activated-${subscription.id} }

);

}

routes/profile.js - keeping a contact's details fresh when your own user updates their profile. Same syncContact call as signup, not a separate endpoint - and no lifecycleStage here on purpose, so a routine profile edit can never accidentally reset someone's stage.

const { syncContact } = require('../lib/alps');

app.patch('/api/profile', async (req, res) => {

const updatedUser = await updateUserInOurDatabase(req.user.id, req.body);

syncContact({

idProperty: 'externalId',

externalId: updatedUser.id,

email: updatedUser.email,

firstName: updatedUser.firstName,

lastName: updatedUser.lastName,

phone: updatedUser.phone,

}).catch((err) => console.error('[Alps sync] profile update failed:', err.message));

res.json({ user: updatedUser });

});

Did this answer your question?