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"}]}'
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.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.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.{"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).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.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.
| Body | Type | |
|---|---|---|
name required | string | The client's name. |
id | string | Lowercase letters, numbers and dashes (made from the name if left out). |
color | string | Brand color, like #0f6fa3. |
website | string | Their 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_id | Only this client's. |
external_ref | Only the campaign with this external_ref. |
status | active (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.
| Body | Type | |
|---|---|---|
name required | string | What to call it. |
destination required | string | The web address its codes open (change it any time). |
external_ref | string | Your own reference for it, unique per account. |
client_id | string | Which client it's for (your own brand if left out). |
id | string | Its ID (made from the name if left out). |
mail_date | string | YYYY-MM-DD, when it mails. |
ends_at | string | When the codes stop (ISO date and time). |
crm_webhook | string | A CRM web address each response is sent to (plans with CRM webhooks). |
copy_from | string | Start 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.
| Body | Type | |
|---|---|---|
name | string | |
destination | string | |
mail_date | string | |
ends_at | string | |
crm_webhook | string | Empty removes it. |
client_id | string | |
external_ref | string |
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.
| Body | Type | |
|---|---|---|
rows required | array | [{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_run | boolean | Check only: how many are new, how many exist, whether they fit this month. |
slug_mode | string | suffix (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 | |
|---|---|
limit | Up to 1,000 (default 500). |
cursor | next_cursor from the page before. |
external_id | Repeat 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.
| Body | Type | |
|---|---|---|
destination required | string | The web address it opens (type url). |
type | string | url (default), vcard, call, sms, email or wifi. |
content | object | What a vcard, call, sms, email or wifi code holds (same fields as the portal). |
label | string | A name for it. |
client_id | string | |
external_id | string | Your own ID for it, unique among one-off codes. |
code | string | A custom code (4–24 letters or numbers). |
utm_source | string | |
utm_medium | string |
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.
| Body | Type | |
|---|---|---|
destination | string | Campaign codes: empty sends it back to the campaign's destination. |
active | boolean | |
label | string | |
external_id | string |
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 | |
|---|---|
size | Finished 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 | |
|---|---|
size | Width 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.
| Body | Type | |
|---|---|---|
name | string | What the owner sees, like "InDesign on Sara's Mac". |
client | string | Your 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).
| Body | Type | |
|---|---|---|
device_code required | string | From 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"
}
}