The PIP Links API

Make campaigns, tracked QR codes and short links, and clients in your PIP Links account from other software: a PressWise job, an InDesign merge, your own scripts. Then read what happened.

Base address https://api.pipic.link/v1 · JSON in and out · openapi.json · Keys in Settings → API keys

A first request

curl https://api.pipic.link/v1/account \
  -H "Authorization: Bearer pl_live_…"

curl https://api.pipic.link/v1/campaigns/spring-tune-up/codes \
  -H "Authorization: Bearer pl_live_…" \
  -H "Idempotency-Key: spring-list-1" \
  -H "Content-Type: application/json" \
  -d '{"rows": [{"external_id": "C1001", "zip": "52240"}, {"external_id": "C1002", "zip": "52245"}]}'
Keys. The account owner makes keys in the portal: Settings → API keys. A key is shown once; keep it like a password, never in a web page. Send it as Authorization: Bearer pl_live_…. A key acts as the person who made it, can be limited to some scopes and one client, can expire, and can be revoked any time.
Never twice. Send an Idempotency-Key header (any unique text) with every POST and PATCH. Retrying with the same key returns the first reply (with Idempotent-Replayed: true) instead of doing it again; the same key with a different request is refused (422). Replies are kept 24 hours. On top of that, external_id on codes and external_ref on campaigns make running the same job again safe.
Limits. Codes made through the API count toward the same monthly codes as the portal. A batch that doesn't fit is refused whole (402, with a limit object saying what to do). Enterprise accounts can keep going past their monthly codes with extra code packs. Each key may make a number of requests a minute (see /v1/account); past it you get 429 with Retry-After. Every reply carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset.
Errors. Errors are JSON: {"error": "limit_reached", "message": "…"}. error is one of invalid_request (400), unauthorized (401), limit_reached (402), forbidden (403), not_found (404), conflict or in_progress (409), too_large (413), idempotency_mismatch (422), rate_limited (429), server_error (500), unavailable (503).
Request log. Every request is logged: when, which key (and the person it acts as), the network address (IP) it came from, the method and path, a SHA-256 of the body, the result and its request ID. Every reply carries X-Request-Id; quote it when you ask about a request. The account owner sees the log, and can download it, in Settings → API keys. It's kept for 13 months.
Stable. v1 only grows: new fields and endpoints may appear, nothing is renamed or removed. Ignore fields you don't know.
Where codes show. API-made campaigns and codes show in the portal like any other (Overview, Campaigns, Links & QR codes, the client's page), marked with the key's name.

Account

GET/v1/account

This key, its account and plan, codes made this month and what's left, extra codes (Enterprise) and the key's rate limits. Check it before a big batch.

Example reply
{
  "account": {
    "id": "pip-iowa-city",
    "name": "PIP Iowa City",
    "plan": "pro",
    "plan_name": "Pro",
    "short_host": "pipic.link"
  },
  "key": {
    "id": "k3x9…",
    "name": "PIP Intelligence",
    "prefix": "pl_live_Ab3d",
    "scopes": [
      "campaigns:read",
      "codes:write"
    ],
    "client_id": null
  },
  "codes": {
    "this_month": 1200,
    "plan_limit": 100000,
    "left_this_month": 98800,
    "resets_at": "2026-11-01T00:00:00.000Z",
    "extra": null
  },
  "rate_limits": {
    "requests_per_minute": 300,
    "rows_per_batch": 5000
  }
}

Clients

GET/v1/clientsclients:read

The account's clients (brands). Your own brand is marked own_brand.

Example reply
{
  "clients": [
    {
      "id": "pip-iowa-city",
      "name": "PIP Iowa City",
      "color": "#002d61",
      "website": "https://pipic.com/",
      "short_host": null,
      "own_brand": true
    }
  ]
}

POST/v1/clientsclients:write

Add a client. Counts against the plan's clients.

BodyType
name requiredstringThe client's name.
idstringLowercase letters, numbers and dashes (made from the name if left out).
colorstringBrand color, like #0f6fa3.
websitestringTheir web address.
Example reply
{
  "client": {
    "id": "lakeside-dental",
    "name": "Lakeside Dental",
    "color": "#0f6fa3",
    "website": null,
    "short_host": null,
    "own_brand": false
  }
}

Campaigns

GET/v1/campaignscampaigns:read

Mailing campaigns, newest first.

Query
client_idOnly this client's.
external_refOnly the campaign with this external_ref.
statusactive (default), archived or all.
Example reply
{
  "campaigns": [
    {
      "id": "spring-tune-up",
      "name": "Spring tune-up",
      "client_id": "lakeside-dental",
      "external_ref": "N208057",
      "destination": "https://example.com/spring",
      "mail_date": "2026-11-02",
      "codes": 2000,
      "archived": false,
      "source": "api:PIP Intelligence"
    }
  ]
}

POST/v1/campaignscampaigns:write

Make a campaign. With an external_ref (a PressWise order number, an InDesign document ID), posting the same one again returns the campaign you already made (created: false) instead of a second one.

BodyType
name requiredstringWhat to call it.
destination requiredstringThe web address its codes open (change it any time).
external_refstringYour own reference for it, unique per account.
client_idstringWhich client it's for (your own brand if left out).
idstringIts ID (made from the name if left out).
mail_datestringYYYY-MM-DD, when it mails.
ends_atstringWhen the codes stop (ISO date and time).
crm_webhookstringA CRM web address each response is sent to (plans with CRM webhooks).
copy_fromstringStart from another campaign's settings (look, versions, fallback page).
Example reply
{
  "campaign": {
    "id": "spring-tune-up",
    "name": "Spring tune-up",
    "external_ref": "N208057",
    "destination": "https://example.com/spring",
    "codes": 0,
    "archived": false
  },
  "created": true
}

GET/v1/campaigns/{id}campaigns:read

One campaign.

PATCH/v1/campaigns/{id}campaigns:write

Change a campaign's name, destination, mail date, end date, CRM webhook, client or external_ref. Printed codes follow at once.

BodyType
namestring
destinationstring
mail_datestring
ends_atstring
crm_webhookstringEmpty removes it.
client_idstring
external_refstring

Codes

POST/v1/campaigns/{id}/codescodes:write

One code per person, up to 5,000 rows a request (500 on Free). The whole batch is checked against the month's codes before anything is made: all of it is made, or none. A row whose external_id already has a code in this campaign gets that code back (created: false), so sending the same list again, or a list with a few names added, never makes duplicates. Send "dry_run": true first to see how many are new and whether they fit.

BodyType
rows requiredarray[{external_id, ref, zip, variant, slug, fields}]. external_id: your ID for the person (customer number, email, row ID), unique per campaign. ref: what the portal shows for them (defaults to external_id). zip: for results by ZIP. variant: A/B version. slug: start the code with their name (JORDAN7K). fields: up to 12 columns kept with the code.
dry_runbooleanCheck only: how many are new, how many exist, whether they fit this month.
slug_modestringsuffix (default, JORDAN7K) or plain (JORDAN when it's free).
Example reply
{
  "campaign_id": "spring-tune-up",
  "created": 1,
  "existing": 1,
  "codes": [
    {
      "code": "JORDAN7K",
      "url": "https://pipic.link/JORDAN7K",
      "qr_data": "HTTPS://PIPIC.LINK/JORDAN7K",
      "print_url": "pipic.link/JORDAN7K",
      "external_id": "C1001",
      "ref": "C1001",
      "zip": "52240",
      "created": true
    },
    {
      "code": "K7M2QX",
      "url": "https://pipic.link/K7M2QX",
      "qr_data": "HTTPS://PIPIC.LINK/K7M2QX",
      "print_url": "pipic.link/K7M2QX",
      "external_id": "C1002",
      "created": false
    }
  ]
}

GET/v1/campaigns/{id}/codescodes:read

A campaign's codes, 500 at a time (next_cursor for the next page), or the ones for some external_ids.

Query
limitUp to 1,000 (default 500).
cursornext_cursor from the page before.
external_idRepeat for up to 100 people.

POST/v1/codescodes:write

A one-off code (Links & QR codes): a web page, contact card, call, text, email or Wi-Fi. With an external_id, sending it again returns the same code.

BodyType
destination requiredstringThe web address it opens (type url).
typestringurl (default), vcard, call, sms, email or wifi.
contentobjectWhat a vcard, call, sms, email or wifi code holds (same fields as the portal).
labelstringA name for it.
client_idstring
external_idstringYour own ID for it, unique among one-off codes.
codestringA custom code (4–24 letters or numbers).
utm_sourcestring
utm_mediumstring
Example reply
{
  "code": {
    "code": "SPRING",
    "url": "https://pipic.link/SPRING",
    "qr_data": "HTTPS://PIPIC.LINK/SPRING",
    "print_url": "pipic.link/SPRING",
    "type": "url",
    "destination": "https://example.com/spring"
  },
  "created": true
}

GET/v1/codes/{code}codes:read

One code: where it goes, its scans so far.

PATCH/v1/codes/{code}codes:write

Change where a code goes or turn it off. One-off codes can also change their label, type and content.

BodyType
destinationstringCampaign codes: empty sends it back to the campaign's destination.
activeboolean
labelstring
external_idstring

GET/v1/codes/{code}/qr.pdfcodes:read

Print-ready artwork for one code: vector PDF in CMYK, in the code's saved look (its own, else its campaign's), with the logo when the look has one.

Query
sizeFinished width in inches, 0.5 to 12.

GET/v1/codes/{code}/qr.svgcodes:read

The same code as an SVG for screens (RGB, no logo).

Query
sizeWidth in inches.

Results

GET/v1/campaigns/{id}/statsstats:read

Codes, people who responded, scans, conversions and form responses, by day (90 days), by ZIP and by version. Bots and email scanners are already set aside.

Example reply
{
  "stats": {
    "campaign_id": "spring-tune-up",
    "codes": 2000,
    "people": 96,
    "response_rate": 4.8,
    "scans": 131,
    "set_aside": {
      "suspect": 4,
      "bots": 22
    },
    "conversions": 12,
    "form_responses": 30,
    "by_day": [
      {
        "day": "2026-11-05",
        "scans": 40
      }
    ],
    "by_zip": [
      {
        "zip": "52245",
        "mailed": 400,
        "responded": 45,
        "rate": 11.25
      }
    ]
  }
}

Connect a device

POST/v1/deviceno key

For apps (like the InDesign plugin): start a connection. Show the user_code and open verification_url; the account owner approves it in the portal.

BodyType
namestringWhat the owner sees, like "InDesign on Sara's Mac".
clientstringYour app, like indesign.
Example reply
{
  "device_code": "pld_…",
  "user_code": "KDPW-3QXT",
  "verification_url": "https://admin.pipic.link/#/settings/api?connect=KDPW-3QXT",
  "expires_in": 900,
  "interval": 5
}

POST/v1/device/tokenno key

Check every interval seconds. 202 while it waits; once approved, the app's own key (shown this once).

BodyType
device_code requiredstringFrom POST /v1/device.
Example reply
{
  "status": "approved",
  "api_key": "pl_live_…",
  "key": {
    "id": "k…",
    "name": "InDesign on Sara's Mac",
    "scopes": [
      "codes:write"
    ]
  },
  "account": {
    "id": "pip-iowa-city",
    "name": "PIP Iowa City"
  }
}