---
name: up2data-api
description: Use when wiring the Up2Data API ({legacy}) into an existing application or platform - choosing between webhook delivery, polling and live enrichment, authenticating and caching the token, submitting profiles and companies in bulk, enriching LinkedIn activity, latest posts and individual post URLs, running Sales Navigator searches, and handling the enriched data on arrival.
---

# Up2Data API ({legacy}) - Integration Guide

Up2Data enriches LinkedIn profiles and companies **in bulk**. You submit many LinkedIn URLs, slugs, or Sales Navigator search links in one request; the API enqueues them and returns immediately. Enriched data arrives later, either pushed to your webhook endpoint or fetched with `GET /open-refresh/list`.

Beyond profiles and companies the same bulk model covers a profile's LinkedIn **activity** (`/open-refresh/activity`, or one list at a time with `/open-refresh/posts`, `/open-refresh/comments` and `/open-refresh/reactions`), its **latest post** (`/open-refresh/latest-post`), and **individual post URLs** (`/open-refresh/post-by-url`).

**Every bulk endpoint is asynchronous** - it hands back a queue id, never the data - so the first design decision is how the data gets back to you. The two **live enrichment** endpoints, [`POST /open-refresh/profile`](https://up2data.ai/docs/legacy/api/profiles-sync.md) and [`POST /open-refresh/company`](https://up2data.ai/docs/legacy/api/companies-sync.md), are the exception: one record each, returned in the response body, no queue involved.

## How to use this skill

- Answer only from this file and the full reference at https://up2data.ai/api-docs.md.
- If something is not covered in one of those two places, say so. Do not invent an endpoint, a request field, a query parameter, or a response field.
- Prefer webhooks over polling unless the user has a specific reason to poll.
- Every example is runnable once the placeholders are filled in. Keep them that way when you adapt them.

## Base URL

```
https://api.up2data.ai/api
```

## Endpoints

| Method | Path | Credits | Mode |
|---|---|---|---|
| POST | [`/api-auth/authenticate`](https://up2data.ai/docs/legacy/api/auth-authenticate/) | Free | Sync |
| POST | [`/open-refresh/profile`](https://up2data.ai/docs/legacy/api/profiles-sync/) | 2 credits - 4 with withFollowersAndConnections or withFullSkillsAndEndorsements (not stacking) | Sync |
| POST | [`/open-refresh/profiles-bulk`](https://up2data.ai/docs/legacy/api/profiles-bulk/) | 1 credit - 2 with withFollowersAndConnections enabled; withFullSkillsAndEndorsements adds additional credits on top of the base rate | Async |
| POST | [`/open-refresh/activity`](https://up2data.ai/docs/legacy/api/profiles-activity/) | 4 credits | Async |
| POST | [`/open-refresh/posts`](https://up2data.ai/docs/legacy/api/profiles-posts/) | 2 credits | Async |
| POST | [`/open-refresh/comments`](https://up2data.ai/docs/legacy/api/profiles-comments/) | 2 credits | Async |
| POST | [`/open-refresh/reactions`](https://up2data.ai/docs/legacy/api/profiles-reactions/) | 2 credits | Async |
| POST | [`/open-refresh/latest-post`](https://up2data.ai/docs/legacy/api/profiles-latest-post/) | 5 credits | Async |
| POST | [`/open-refresh/company`](https://up2data.ai/docs/legacy/api/companies-sync/) | 2 credits | Sync |
| POST | [`/open-refresh/companies-bulk`](https://up2data.ai/docs/legacy/api/companies-bulk/) | 1 credit | Async |
| POST | [`/open-refresh/post-by-url`](https://up2data.ai/docs/legacy/api/posts-by-url/) | 1 credit | Async |
| POST | [`/open-refresh/search`](https://up2data.ai/docs/legacy/api/sales-search/) | 3 credits per result - 4 credits per result with withFollowersAndConnections on lead searches only; company searches stay at the base rate | Async |
| GET | [`/open-refresh/list`](https://up2data.ai/docs/legacy/api/queues-list/) | Free | Sync |
| GET | [`/open-refresh/status`](https://up2data.ai/docs/legacy/api/queues-status/) | Free | Sync |

## Step 1 - Pick your delivery path

This choice determines the whole shape of the integration. Make it first.

Enriching **one** record on demand, in an interactive flow? Use live enrichment - [`POST /open-refresh/profile`](https://up2data.ai/docs/legacy/api/profiles-sync.md) or [`POST /open-refresh/company`](https://up2data.ai/docs/legacy/api/companies-sync.md) returns the enriched document in the response body. It is capped at 10 requests per 10 seconds per team, so it is the wrong tool for a batch. Everything else goes through a queue, delivered one of two ways:

| | Webhooks (recommended) | Polling |
|---|---|---|
| How results arrive | Up2Data `POST`s them to your HTTPS endpoint as items finish | You fetch them yourself |
| What you build | One HTTP route | A queue-id store, a timer, a pagination loop |
| Is `queueId` needed? | No - ignore it | Yes - it is the handle for everything |
| Setup | Register the URL in Settings -> Integrations -> Create Webhook | None |

## Step 2 - Authenticate once, cache the token

Exchange an API key for a JWT access token and use it on every request.

The API uses a two-step process: an API key proves who you are once, and a short-lived JWT token proves it on every request after that.

> **Info:** API keys are long-lived credentials and should be stored securely. JWT tokens expire after 24 hours and need to be refreshed.

## Creating an API key

1. Log in to the dashboard
2. Navigate to **Settings** from the sidebar
3. Click on **Integrations**
4. Click **Create API Key**
5. Enter a name for your key (for example, "Production API Key")
6. Copy and securely store the generated key

## Exchanging the key for a token

[`POST /api-auth/authenticate`](https://up2data.ai/docs/legacy/api/auth-authenticate/) is the only endpoint that does not require the `Authorization` header. It exchanges an API key for a JWT access token valid for 24 hours.

```json
{
  "apiKey": "string"
}
```

```bash
curl -X POST https://api.up2data.ai/api/api-auth/authenticate \
  -H "Content-Type: application/json" \
  -d '{"apiKey": "your-api-key-here"}'
```

A successful exchange returns the token:

```json
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```

An invalid key returns 401:

```json
{
  "error": "Not authorized"
}
```

## Using the access token

Every other endpoint requires the token in the `Authorization` header:

```bash
Authorization: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```

> **Warning:** Tokens expire after 24 hours and need to be refreshed; re-run the exchange with your API key to get a new one.


A minimal cached-token helper:

```js
let cached = { token: null, expiresAt: 0 };

async function getToken(apiKey) {
  if (cached.token && Date.now() < cached.expiresAt) return cached.token;
  const r = await fetch('https://api.up2data.ai/api/api-auth/authenticate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ apiKey }),
  });
  if (!r.ok) throw new Error(`auth failed: ${r.status}`);
  const { accessToken } = await r.json();
  cached = { token: accessToken, expiresAt: Date.now() + 23 * 60 * 60 * 1000 };
  return accessToken;
}
```

## Step 3 - Submit in bulk

Whatever you submit is normalised before it is stored, and the normalised value is what appears as both `name` and `url` on every webhook callback item. The transform: strip one trailing `/`, then, if the string contains `linkedin.com/`, keep only the last `/`-separated segment.

| You submit | Stored & returned as `name` / `url` |
|---|---|
| `https://www.linkedin.com/in/johndoe/` | `johndoe` |
| `https://linkedin.com/in/johndoe` | `johndoe` |
| `johndoe` | `johndoe` |

```js
// The same transform Up2Data applies. Use it on both sides to key your own records.
function normalize(input) {
  let s = input.endsWith('/') ? input.slice(0, -1) : input;
  if (s.includes('linkedin.com/')) s = s.split('/').pop();
  return s;
}
```

Which endpoint:

| Endpoint | Returns |
|---|---|
| [`POST /open-refresh/profile`](https://up2data.ai/docs/legacy/api/profiles-sync.md) | the enriched document itself |
| [`POST /open-refresh/profiles-bulk`](https://up2data.ai/docs/legacy/api/profiles-bulk.md) | `queueId` (string) |
| [`POST /open-refresh/activity`](https://up2data.ai/docs/legacy/api/profiles-activity.md) | `queueId` (string) |
| [`POST /open-refresh/posts`](https://up2data.ai/docs/legacy/api/profiles-posts.md) | `queueId` (string) |
| [`POST /open-refresh/comments`](https://up2data.ai/docs/legacy/api/profiles-comments.md) | `queueId` (string) |
| [`POST /open-refresh/reactions`](https://up2data.ai/docs/legacy/api/profiles-reactions.md) | `queueId` (string) |
| [`POST /open-refresh/latest-post`](https://up2data.ai/docs/legacy/api/profiles-latest-post.md) | `queueId` (string) |
| [`POST /open-refresh/company`](https://up2data.ai/docs/legacy/api/companies-sync.md) | the enriched document itself |
| [`POST /open-refresh/companies-bulk`](https://up2data.ai/docs/legacy/api/companies-bulk.md) | `queueId` (string) |
| [`POST /open-refresh/post-by-url`](https://up2data.ai/docs/legacy/api/posts-by-url.md) | `queueId` (string) |
| [`POST /open-refresh/search`](https://up2data.ai/docs/legacy/api/sales-search.md) | `queueIds` (array - one per link) |
| [`GET /open-refresh/list`](https://up2data.ai/docs/legacy/api/queues-list.md) | the queue status or page you asked for |
| [`GET /open-refresh/status`](https://up2data.ai/docs/legacy/api/queues-status.md) | the queue status or page you asked for |

## Reading results back

Track a queue with list and status, and poll until it completes.

Endpoints that enqueue work hand you a `queueId` (or `queueIds` for Search). Read the results back with `list` and `status`, or skip polling entirely and let a webhook push them to you.

## List results

[`GET /open-refresh/list`](https://up2data.ai/docs/legacy/api/queues-list/) returns a queue's items, paginated.

| Field | Type | Notes |
|---|---|---|
| `queueId` | string | Required |
| `page` | string | Required, 0-indexed |
| `limit` | string | Required, 1 to 25 |
| `failed` | boolean | Optional, defaults to false. Set true to see items whose webhook delivery failed |

```bash
curl -X GET "https://api.up2data.ai/api/open-refresh/list?queueId=507f1f77bcf86cd799439011&page=0&limit=10" \
  -H "Authorization: YOUR_ACCESS_TOKEN"
```

The shape of each item is decided by the queue itself: an enrichment queue returns [Profile](https://up2data.ai/docs/legacy/schemas/profile/) or [Company](https://up2data.ai/docs/legacy/schemas/company/) documents, an activity queue returns [Activity](https://up2data.ai/docs/legacy/schemas/activity/) documents.

```json
{
  "items": "Profile[] | Company[] | Activity[]",
  "total": "number",
  "totalResults": "number (Sales Navigator queues only)"
}
```

> **Info:** `totalResults` is how many results the Sales Navigator search itself reported, so it is usually larger than `total`, since scraping is capped by the link's `limit`. Only queues created by [`/open-refresh/search`](https://up2data.ai/docs/legacy/api/sales-search/) carry it, and only once the first page has been scraped.

## Check status

[`GET /open-refresh/status`](https://up2data.ai/docs/legacy/api/queues-status/) reports how far a queue has gotten.

```bash
curl -X GET "https://api.up2data.ai/api/open-refresh/status?queueId=507f1f77bcf86cd799439011" \
  -H "Authorization: YOUR_ACCESS_TOKEN"
```

```json
{
  "queueId": "string",
  "status": "string (\"pending\" | \"completed\")",
  "processed": "number",
  "total": "number"
}
```

```json
{
  "queueId": "507f1f77bcf86cd799439011",
  "status": "pending",
  "processed": 35,
  "total": 50
}
```

## Polling

Poll `status` until it reports `completed`, then fetch the results:

```bash
# Poll every 30 seconds until done
while true; do
  STATUS=$(curl -s -H "Authorization: $TOKEN" \
    "https://api.up2data.ai/api/open-refresh/status?queueId=$QUEUE_ID")

  if echo "$STATUS" | grep -q '"completed"'; then
    # Now fetch the results
    curl -H "Authorization: $TOKEN" \
      "https://api.up2data.ai/api/open-refresh/list?queueId=$QUEUE_ID&page=0&limit=25"
    break
  fi

  sleep 30
done
```

> **Info:** Polling costs requests and adds latency for no benefit once nothing has changed, and it counts toward your rate limits. Configure a webhook instead and skip this loop entirely. See the [Webhooks guide](https://up2data.ai/docs/legacy/guides/webhooks/).


## Step 4 - Handle the data on arrival

Every webhook POST body is a JSON array of callback items. The `type` field is one of "Profile", "Company", "Activity", "ActivityPosts", "ActivityComments", "ActivityReactions", "LatestPost" or "PostByUrl". `totalResults`: only sent by callbacks from a Sales Navigator queue (sales-search). It is how many results the search itself reported, not how many you receive, and it is repeated on every item of the callback. Absent until the first page of that queue has been scraped.

```json
{
  "queueId": "string",
  "name": "string",
  "url": "string",
  "type": "string (\"Profile\" | \"Company\" | \"Activity\" | \"ActivityPosts\" | \"ActivityComments\" | \"ActivityReactions\" | \"LatestPost\" | \"PostByUrl\")",
  "status": "number",
  "_id": "string",
  "totalResults": "number (Sales Navigator callbacks only)",
  "result": { ... }
}
```

> **Warning:** `type` is capitalised (`"Profile"`, not `"profile"`). Comparing against lowercase matches nothing and silently drops every item.

> **Info:** `status` is documented as a number; every current example shows `200` on a successful item. Do not assume a fixed enum for it - check `GET /open-refresh/status` or `list` if you need a queue's real progress.

The three single-list endpoints (`posts`, `comments`, `reactions`) omit the lists you did not ask for rather than returning them empty, and the callback's `partial` object carries only the flag for the list you asked for.

## Webhooks: configuration and routing

Receive enrichment results automatically instead of polling, with tags, retries and the callback item shape.

Webhooks push enrichment results to your server as soon as they are ready, instead of you polling `/open-refresh/status`.

## Setting up webhooks

1. Log in to the dashboard
2. Navigate to **Settings** from the sidebar
3. Click on **Integrations**
4. Click **Create Webhook**
5. Enter a name and your webhook URL (must be HTTPS)
6. Optionally add custom headers for authentication
7. Optionally add tags so individual requests can target this webhook

> **Info:** Your webhook endpoint must respond with a 2xx status code within 3 seconds. Failed deliveries are retried with progressive backoff: 10 minutes, 1 hour, 8 hours, then 24 hours.

## Custom headers

Each webhook has a name, an endpoint URL, and optional custom headers for authenticating incoming requests:

```json
[
  { "name": "X-Webhook-Secret", "value": "your-secret-key" },
  { "name": "Authorization", "value": "your-token" }
]
```

> **Info:** Webhook payloads can contain both profiles and companies in the same request.

## Routing callbacks with tags

Tag your webhooks in the dashboard, then name those tags in a request with `webhookTags` to decide where that request's callbacks go. This is how you keep environments apart: give development, staging and production endpoints their own tags, and each one receives only the results it asked for.

```bash
curl -X POST https://api.up2data.ai/api/open-refresh/profiles-bulk \
  -H "Content-Type: application/json" \
  -H "Authorization: YOUR_ACCESS_TOKEN" \
  -d '{
    "name": "Q1 Leads Enrichment",
    "profiles": ["https://www.linkedin.com/in/johndoe"],
    "priority": 2,
    "webhookTags": ["staging"]
  }'
```

| `webhookTags` value | Behavior |
|---|---|
| Omitted | Every webhook on the team receives the callbacks, exactly as before; existing integrations need no changes |
| One or more tags | Every webhook carrying any of the named tags receives the callbacks; webhooks can share a tag, so one tag can deliver to a whole group |
| `[]` (empty array) | Callbacks are switched off for that request; poll `status` and `list` instead |

Every enqueue response echoes back the webhooks your tags resolved to, so you can confirm the routing as soon as you wire an environment up:

```json
{
  "message": "1 items have been enqueued.",
  "queueId": "507f1f77bcf86cd799439011",
  "webhooks": [
    {
      "_id": "6512c1a4f1b2c3d4e5f60789",
      "name": "Staging",
      "url": "https://staging.example.com/hooks/up2data"
    }
  ]
}
```

> **Warning:** A tag that matches no webhook is rejected with 400 and nothing is enqueued, so a typo never leaves you waiting on callbacks that were never going to arrive.

```json
{
  "message": "No webhook matches webhookTags: stage. Available tags: dev, prod, staging."
}
```

> **Info:** Tags are matched after being lowercased and trimmed, may contain letters, digits, dot, underscore and hyphen, and are limited to 32 characters. A single request may name at most 10 of them. Tags are resolved each time a callback is sent, so a webhook you tag later still receives results from requests that are still running.

## The callback payload

Each webhook request is an array of callback items. `type` says what kind of record the item carries, and `result` holds the full enriched object.

```json
{
  "queueId": "string",
  "name": "string",
  "url": "string",
  "type": "string (\"Profile\" | \"Company\" | \"Activity\" | \"ActivityPosts\" | \"ActivityComments\" | \"ActivityReactions\" | \"LatestPost\" | \"PostByUrl\")",
  "status": "number",
  "_id": "string",
  "totalResults": "number (Sales Navigator callbacks only)",
  "result": {}
}
```

See the [Callback item schema](https://up2data.ai/docs/legacy/schemas/callback-item/) for the full field list, and [Profile](https://up2data.ai/docs/legacy/schemas/profile/), [Company](https://up2data.ai/docs/legacy/schemas/company/), [Activity](https://up2data.ai/docs/legacy/schemas/activity/) and [Post by URL](https://up2data.ai/docs/legacy/schemas/post-by-url/) for what `result` contains for each `type`.

> **Info:** `totalResults` is only sent by callbacks from a Sales Navigator queue. It is how many results the search itself reported, not how many you receive, it is repeated on every item, and it is absent until the first page of that queue has been scraped.

## Handling failures

| Attempt | Delay |
|---|---|
| Retry 1 | 10 minutes |
| Retry 2 | 1 hour |
| Retry 3 | 8 hours |
| Retry 4 | 24 hours |

After 4 failed attempts, the webhook delivery is abandoned. Use [`GET /open-refresh/list`](https://up2data.ai/docs/legacy/api/queues-list/) with `failed=true` to find items whose webhook delivery failed.

## Webhooks vs polling

| | Polling | Webhooks |
|---|---|---|
| How it works | Your server repeatedly calls the API to check for updates | Results are pushed to your server the moment they are ready |
| Cost | Most requests return no new data, and count toward your rate limits | Zero wasted requests |
| Latency | Delayed by your poll interval | Real time, no delays |
| Code | You build and maintain polling loops, timers and state tracking | Point at an endpoint URL and process incoming data |

```bash
# Poll every 30 seconds until done
while true; do
  STATUS=$(curl -s -H "Authorization: $TOKEN" \
    "https://api.up2data.ai/api/open-refresh/status?queueId=$QUEUE_ID")

  if echo "$STATUS" | grep -q '"completed"'; then
    # Now fetch the results
    curl -H "Authorization: $TOKEN" \
      "https://api.up2data.ai/api/open-refresh/list?queueId=$QUEUE_ID&page=0&limit=25"
    break
  fi

  sleep 30
done
```

```bash
# Your server just listens for incoming POST requests
# No polling, no timers, no state management

# Up2Data sends results as soon as they're ready:
# POST https://your-server.com/webhook
# Content-Type: application/json
# X-Webhook-Secret: your-secret
#
# [{ "type": "Profile", "result": { } }]
```


## Credits and limits

How enrichment, activity and search rates work, plus reserve and refund rules.

Every endpoint spends credits differently: enqueue endpoints charge per item that survives normalization, the two live enrichment endpoints charge only when they return 200 or 404, and the Search API reserves credits upfront and refunds what it does not use. This page collects every rule in one place.

## Enqueue endpoints

These endpoints normalize and de-duplicate your input before charging, so you pay for what got `enqueued`, not for what you sent. If nothing survives filtering you get a 400 and no queue is created, so nothing is charged.

| Endpoint | Rate | Notes |
|---|---|---|
| [`POST /open-refresh/profiles-bulk`](https://up2data.ai/docs/legacy/api/profiles-bulk/) | 1 credit per profile, 2 with `withFollowersAndConnections` | Charged even if the counts turn out unavailable. `withFullSkillsAndEndorsements` costs additional credits on top of the base rate |
| [`POST /open-refresh/companies-bulk`](https://up2data.ai/docs/legacy/api/companies-bulk/) | 1 credit per company | |
| [`POST /open-refresh/activity`](https://up2data.ai/docs/legacy/api/profiles-activity/) | 4 credits per profile | Covers posts, comments and reactions together |
| [`POST /open-refresh/posts`](https://up2data.ai/docs/legacy/api/profiles-posts/) | 2 credits per profile | Posts only |
| [`POST /open-refresh/comments`](https://up2data.ai/docs/legacy/api/profiles-comments/) | 2 credits per profile | Comments only |
| [`POST /open-refresh/reactions`](https://up2data.ai/docs/legacy/api/profiles-reactions/) | 2 credits per profile | Reactions only |
| [`POST /open-refresh/latest-post`](https://up2data.ai/docs/legacy/api/profiles-latest-post/) | 5 credits per profile | |
| [`POST /open-refresh/post-by-url`](https://up2data.ai/docs/legacy/api/posts-by-url/) | 1 credit per post URL | De-duplicated on the post itself, not the URL string |

> **Info:** Asking for posts, comments and reactions separately costs 6 credits per profile against 4 for a single `/open-refresh/activity` call. Prefer `/activity` when you want the whole picture.

## Live enrichment endpoints

[`POST /open-refresh/profile`](https://up2data.ai/docs/legacy/api/profiles-sync/) and [`POST /open-refresh/company`](https://up2data.ai/docs/legacy/api/companies-sync/) return the enriched document directly, with no queue. They bill on the response, not on enqueue:

| Endpoint | Rate |
|---|---|
| `POST /open-refresh/profile` | 2 credits, or 4 with `withFollowersAndConnections` or `withFullSkillsAndEndorsements` (does not stack) |
| `POST /open-refresh/company` | 2 credits |

> **Warning:** Sync not-found is billed. A 200 and a 404 both charge the full rate; only 400, 403, 429 and 503 charge nothing. See the [Errors guide](https://up2data.ai/docs/legacy/guides/errors/) for every status these two endpoints return.

For volume, [`profiles-bulk`](https://up2data.ai/docs/legacy/api/profiles-bulk/) and [`companies-bulk`](https://up2data.ai/docs/legacy/api/companies-bulk/) enqueue many items at the lower bulk rate above instead of one live call per item.

## Search API

[`POST /open-refresh/search`](https://up2data.ai/docs/legacy/api/sales-search/) costs 3x the base Open Refresh rate, reserved upfront against each link's `limit` and adjusted once the queue finishes.

| Rule | Value |
|---|---|
| Base rate | 3 credits per result |
| Lead searches with `withFollowersAndConnections` | 4 credits per result (company searches stay at 3) |
| Default limit | 2,500 for `/sales/search/people`, 1,000 for `/sales/search/company`, used and reserved against when `limit` is omitted |
| Reservation | `limit x rate` per link, charged when you enqueue |
| Refunds | Unused credits are refunded automatically once processing completes |

For example, a lead search with `"limit": 500` reserves a maximum of 1,500 credits, or 2,000 with `withFollowersAndConnections`.

> **Warning:** Credits are reserved at request time. Make sure your balance covers `limit x rate` for every link before submitting.

## Billed vs free (live enrichment)

| Status | Billed | Why |
|---|---|---|
| 200 | Yes | Profile or company found |
| 404 | Yes | Nothing found on LinkedIn for that slug |
| 400 | No | Bad input, or the team's daily or monthly spending cap was reached |
| 403 | No | Not enough credits on the team |
| 429 | No | Rate limited |
| 503 | No | No worker became available within 10 seconds, or the crawl failed; retry later |

This table covers `/open-refresh/profile` and `/open-refresh/company` only. Every other endpoint enqueues, so its credit rules are the normalization rules above, except the Search API, which reserves against each link's limit and refunds unused credits.


| Other limit | Value |
|---|---|
| Request body | 50 MB |
| `profiles` / `companies` / `posts` array size | No documented maximum |
| Live enrichment (`profile` + `company`, shared) | 10 requests per 10 seconds per team |
| Access token lifetime | 24 hours |
| `/open-refresh/list` `limit` (page size) | 1-25, `page` 0-indexed |
| `webhookTags` per request | 10, max 32 characters each |

> **Info:** Sales Navigator reports result counts in ranges - `1k+` can mean 1,000-1,499, `1.5k+` can mean 1,500-1,999, and so on. Size a search link's `limit` with that slack in mind.

## Errors

Every error shape and status code the API returns, and which ones are billed.

Every error shape returned by legacy API endpoints, in one place: what triggered it and whether it was billed.

## Authentication

| Status | Body | When |
|---|---|---|
| 401 | `{"error": "Not authorized"}` | The API key sent to [`POST /api-auth/authenticate`](https://up2data.ai/docs/legacy/api/auth-authenticate/) does not match a valid key |

## Bulk enqueue endpoints

[`profiles-bulk`](https://up2data.ai/docs/legacy/api/profiles-bulk/), [`companies-bulk`](https://up2data.ai/docs/legacy/api/companies-bulk/), [`activity`](https://up2data.ai/docs/legacy/api/profiles-activity/), [`posts`](https://up2data.ai/docs/legacy/api/profiles-posts/), [`comments`](https://up2data.ai/docs/legacy/api/profiles-comments/), [`reactions`](https://up2data.ai/docs/legacy/api/profiles-reactions/) and [`latest-post`](https://up2data.ai/docs/legacy/api/profiles-latest-post/) all return the same shape when nothing survives normalization (`companies-bulk` says "companies" instead of "profiles"):

```json
{
  "message": "No valid profiles to enqueue: of 4 submitted, 1 were invalid and 3 were duplicates."
}
```

[`post-by-url`](https://up2data.ai/docs/legacy/api/posts-by-url/) returns the equivalent for post URLs:

```json
{
  "message": "No valid post urls to enqueue: of 4 submitted, 1 were invalid and 3 were duplicates."
}
```

Any endpoint that accepts `webhookTags` rejects a tag that matches no webhook:

```json
{
  "message": "No webhook matches webhookTags: stage. Available tags: dev, prod, staging."
}
```

None of these create a queue, and none of them are billed.

## Live enrichment endpoints

[`profile`](https://up2data.ai/docs/legacy/api/profiles-sync/) and [`company`](https://up2data.ai/docs/legacy/api/companies-sync/) return the enriched document directly, so their errors and billing follow a different rule:

| Status | Body / meaning | Billed |
|---|---|---|
| 200 | The enriched document | Yes |
| 404 | `{"message": "Profile not found on LinkedIn"}` or `{"message": "Company not found on LinkedIn"}` | Yes |
| 400 | Input is not a matching LinkedIn URL or slug (`linkedin.com/in/` for profiles, `linkedin.com/company/` for companies), or the team's daily or monthly spending cap has been reached | No |
| 403 | Not enough credits on the team | No |
| 429 | Rate limited | No |
| 503 | No worker became available within 10 seconds, or the crawl could not be completed; retry later | No |

> **Warning:** Both endpoints share a limit of 10 requests per 10 seconds per team. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the window clears) headers, and a 429 also carries `Retry-After` so you know how long to wait before retrying.

See the [Credits guide](https://up2data.ai/docs/legacy/guides/credits/) for how these statuses interact with billing across every endpoint.


## Integration recipe: webhook receiver (Node + Express)

Acknowledge inside 3 seconds, then process. The payload is an array and may mix profiles and companies.

```js
import express from 'express';
const app = express();
app.use(express.json({ limit: '25mb' }));

app.post('/hooks/up2data', (req, res) => {
  const items = Array.isArray(req.body) ? req.body : [];
  res.sendStatus(200); // acknowledge first, process after
  setImmediate(() => handle(items));
});

async function handle(items) {
  for (const item of items) {
    // item = { queueId, name, url, type, status, _id, result }
    if (item.type === 'Profile') {
      const p = item.result;
      // ---- your integration seam ----
      // await db.contacts.upsert({ linkedinSlug: item.url, ...p });
    } else if (item.type === 'Company') {
      const c = item.result;
      // await db.accounts.upsert({ linkedinSlug: item.url, ...c });
    }
  }
}
```

## Gotchas checklist

Check every one of these before calling an integration done.

- [ ] **`Authorization` carries the raw token - no `Bearer` prefix.** The most common cause of unexplained 401s.
- [ ] **The webhook `type` is capitalised.** It is one of "Profile", "Company", "Activity", "ActivityPosts", "ActivityComments", "ActivityReactions", "LatestPost" or "PostByUrl".
- [ ] Cache the access token - it is valid for 24 hours. Re-authenticate on expiry or on a 401, not per request.
- [ ] You are billed for `enqueued`, not for the array you sent. Read `skipped` to see what was dropped as invalid or duplicate; if nothing survives you get a 400 and no queue.
- [ ] `/open-refresh/posts` is the **posts list of an activity record**, not the latest post. The single newest item is `/open-refresh/latest-post`, and its callback `type` is `"LatestPost"`, not `"Posts"`.
- [ ] The three single-list endpoints omit the lists you did not ask for, rather than returning them empty.
- [ ] `totalResults`: only sent by callbacks from a Sales Navigator queue (sales-search). It is how many results the search itself reported, not how many you receive, and it is repeated on every item of the callback. Absent until the first page of that queue has been scraped.
- [ ] `priority` is `1` = High, `2` = Normal, `3` = Low - a number, not a string.
- [ ] `/open-refresh/list` needs `queueId`, `page` (0-indexed) and `limit` (1-25) on every request.
- [ ] `withFollowersAndConnections` defaults to `false`; on `search` it applies to lead searches only.
- [ ] `withFullSkillsAndEndorsements` defaults to `false`; without it `skills` is capped at the first 20 the profile lists.
- [ ] `webhookTags` omitted means every webhook on the team; `[]` switches callbacks off; an unmatched tag returns 400 and enqueues nothing.
- [ ] Only `POST /open-refresh/profile` and `POST /open-refresh/company` return enriched data synchronously; both bill on a 404 as well as a 200.

## Full reference

The complete field-by-field schema for every request body, response, and webhook payload lives at https://up2data.ai/api-docs.md. Consult it for any field not listed above. If a field or endpoint appears in neither this skill nor that reference, it does not exist - say so rather than inventing one.
