API
Overview of the v1 API — the Bearer token, the 4 routes, the quotas per plan, the error codes and the response format.
Updated on 25 September 2026
On this page
- Authentication
- The routes
- The format
- The HTTP codes
- Limits and plans
- Troubleshooting
- Why 401 when the token was just created?
- Why 404 on a project I see in the application?
- Why did the quota drop without my CI having run?
- POST /api/v1/check
- The request
- The response
- The HTTP codes
- Limits and plans
- Troubleshooting
- GET /api/v1/projects
- GET /api/v1/projects
- GET /api/v1/projects/{id}/incidents
- GET /api/v1/projects/{id}/last-ship
- GET /api/v1/me
- GET /api/v1/projects/{id}
- GET /api/v1/projects/{id}/ships
- Codes and errors
- Limits and plans
- Troubleshooting
- The 6 checks of a URL
- What is checked
- Going further
- Troubleshooting
- Exports
- What PostShip exports
- The CSV format
- What triggers an alert
- Limits and plans
- Troubleshooting
PostShip's API does 2 things, and 2 only: run a check on a URL — the same as the cycle — and read the state of your projects from your own tools. Everything is JSON, on https://postship.fr/api/v1/, with a token in a header. No route writes into a project, and there won't be one: an API token lives in a CI environment variable, and a deletion at the end of it would be one copy-paste away. What changes a project goes through the application, where someone is signed in.
Authentication
A psk_… key, created from Workspace settings → API, passed in the Authorization header. The same key serves all routes.
export POSTSHIP_TOKEN=psk_…
curl -sS https://postship.fr/api/v1/projects \
-H "Authorization: Bearer $POSTSHIP_TOKEN"The key goes through an environment variable, never as a command argument: an argument can be read in the runner's process list, where secret masking doesn't apply. An invalid or revoked token answers 401 with {"error":"Invalid or revoked API key."}, on all routes the same way. PostShip only keeps the token's SHA-256 fingerprint and notes the date of its last use — that's what makes it possible to revoke one without wondering whether it still runs somewhere.
A token sees exactly what its owner sees: their projects, and those shared with them as an accepted member. A pending invitation isn't one.
The routes
| Method | Route | What it does | Quota |
|---|---|---|---|
POST | /api/v1/check | Checks a URL now and returns the Ship Score (reference) | Counted |
GET | /api/v1/projects | Your projects: id, name, address, last state (reference) | Discover |
GET | /api/v1/projects/{id}/incidents | The URLs failing right now, and since when | Discover |
GET | /api/v1/projects/{id}/last-ship | The last production deploy, with its score | Discover |
Reading doesn't consume your monthly counter. That counter pays for real work — requests to your site; a read only costs one request to our database. Mixing them would make you pay for a dashboard at the price of a probe.
The format
Responses are JSON, in UTF-8, with Cache-Control: no-store on reads: an API response isn't cached. Dates are in ISO 8601 (UTC). Errors always carry an error field with a sentence, and sometimes one more field (quota on a 429):
{ "error": "Monthly quota reached (500/500 checks).", "quota": { "used": 500, "limit": 500, "remaining": 0 } }The HTTP codes
| Code | When | Body |
|---|---|---|
200 | Everything went well | The resource |
400 | URL refused on /check: not https, credentials in the URL, private or unresolvable host | {"error":"…"} with the exact reason |
401 | Key missing, malformed, unknown or revoked | {"error":"Invalid or revoked API key."} |
404 | Unknown project — or one this token isn't allowed to see | {"error":"Project not found."} |
429 | Monthly check quota reached | {"error":"…", "quota":{…}} |
A project that doesn't exist and a project you aren't allowed to see answer the same thing. Telling them apart would confirm the existence of an id to whoever guesses it — and a confirmed id is half the work of an inventory.
Limits and plans
Checks launched through the API are counted per calendar month (UTC) and per workspace, all of its tokens together.
| Discover | Pro | Pro Plus | |
|---|---|---|---|
| Checks per month | 30 | 500 | 3000 |
Reads (projects, incidents, last-ship) | Unlimited | Unlimited | Unlimited |
| Active tokens | 5 | 5 | 5 |
Beyond the quota, the call is refused with the exact count — never a degraded result: a CI that lies is worse than a CI that breaks. The slot is taken before the check, atomically: 100 calls launched in parallel don't all pass under the limit. The month's counter can be read in Workspace settings → API (“12/500 CI checks this month”). See the plans.
Troubleshooting
Why 401 when the token was just created?
Check the header: Authorization: Bearer psk_…, with the space after Bearer, and the whole token — it starts with psk_ and is only shown once. A revoked token doesn't come back: create another one.
Why 404 on a project I see in the application?
The token belongs to another account than the one that sees the project, or you're invited to that project without having accepted the invitation. A token only sees its owner's projects and those where they are an accepted member.
Why did the quota drop without my CI having run?
The counter is per workspace: another token of the same workspace consumes the same reserve, and a token from another workspace has its own. Each token's last use date, in Workspace settings → API, says which one was used.
POST /api/v1/check
The full reference of the on-demand check — the body, every field of the response, the HTTP codes, and curl, JavaScript and GitHub Actions examples.
POST https://postship.fr/api/v1/check checks a public URL now, with the same engine as the cycle — the 6 checks of a URL — and returns the result in the response, synchronously. A CI that had to poll a queue would wait longer than it takes to check, and each poll would be one more occasion to get it wrong. Count up to 60 seconds.
The request
| Method | POST |
| Headers | Authorization: Bearer psk_… and Content-Type: application/json |
| Body | {"url": "https://your-site.com/page"} — a single key, url, in https |
The URL must be https, without credentials (https://user:pass@… is refused), and lead to a public host: the same guard as for any monitored URL, because a valid token doesn't authorise pointing our probe at a private network. A refused URL consumes no quota: it's checked before the reservation.
export POSTSHIP_TOKEN=psk_…
curl -sS -X POST https://postship.fr/api/v1/check \
-H "Authorization: Bearer $POSTSHIP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-site.com"}'The response
200 with a JSON object:
{
"url": "https://your-site.com/",
"score": 75,
"reason": "−25 missing asset",
"outcome": "fail",
"failed": 1,
"checks": [
{ "kind": "http", "label": "Page and resources", "outcome": "fail", "detail": "2 resources declared but missing" },
{ "kind": "index", "label": "Indexability", "outcome": "pass", "detail": "Crawlable, consistent canonical" },
{ "kind": "og", "label": "Social card", "outcome": "pass", "detail": "Valid title and image" },
{ "kind": "sitemap", "label": "Sitemap", "outcome": "skip", "detail": "No sitemap declared" },
{ "kind": "ssl", "label": "SSL certificate", "outcome": "pass", "detail": "61 days before expiry" },
{ "kind": "ia", "label": "AI visibility", "outcome": "pass", "detail": "Readable and quotable by answer engines" }
],
"quota": { "used": 12, "limit": 500, "remaining": 488 }
}| Field | Type | What it contains |
|---|---|---|
url | string | The checked URL, normalised |
score | number | The Ship Score, from 0 to 100, computed by the function that grades a real deploy |
reason | string or null | The main deduction (“−30 non-indexable site”), null when everything passed |
outcome | "pass" or "fail" | pass if no line is in fail or error; it's the field to fail a step on |
failed | number | The number of lines in fail or error |
checks[] | array | One line per check (see below) |
checks[].kind | string | http, index, og, sitemap, ssl, ia |
checks[].label | string | The readable name: Page and resources, Indexability, Social card, Sitemap, SSL certificate, AI visibility |
checks[].outcome | string | pass, fail, error (PostShip couldn't conclude: timeout, DNS), or skip (nothing to check — a site without a sitemap) |
checks[].detail | string | A sentence, the one a person would say: “Status 200 · 412 ms”, “ChatGPT can't read you” |
quota.used | number | The checks consumed this month, this one included |
quota.limit | number | The plan's cap: 30, 500 or 3000 |
quota.remaining | number | What's left |
A skip line weighs neither on the grade nor on outcome: a site without a sitemap broke nothing. An error line counts as a failure — an unreadable certificate or a page that doesn't respond in time is a problem, not an absence of problem.
What the 6 lines look at is described on the 6 checks of a URL page: it's the same audit, under a budget of 40 requests to the site.
The HTTP codes
| Code | When | Body |
|---|---|---|
200 | The check happened, whatever its verdict | The object above |
400 | Unreadable body or without url | {"error":"Invalid URL (https required)."} |
400 | Refused URL | {"error":"The URL must be https."}, {"error":"Invalid URL (credentials not allowed)."}, or the network guard's reason |
401 | Invalid or revoked key | {"error":"Invalid or revoked API key."} |
429 | Month's quota reached | {"error":"Monthly quota reached (500/500 checks).","quota":{"used":500,"limit":500,"remaining":0}} |
A 200 with outcome: "fail" is a normal response: the site has a problem. A 4xx means the check didn't happen. A CI must tell the two apart — see Check from your CI for the exit codes.
Limits and plans
| Discover | Pro | Pro Plus | |
|---|---|---|---|
| Checks per month | 30 | 500 | 3000 |
Per calendar month (UTC) and per workspace, all of its tokens together. The slot is reserved before the audit, atomically: calls launched in parallel don't exceed the limit. See API and the plans.
Troubleshooting
Why does the response take 20 seconds?
The 6 checks run in parallel under a shared budget; the response arrives in the time of the slowest, usually the resources' or the sitemap's on a slow site. Plan a timeout of at least 60 seconds client-side.
Why is outcome fail when the score is at 90?
An error line fails outcome but only costs 10 points (“failing URL”). That's intended: a score is for comparing, a verdict is for blocking. Look at checks[] for the line in question.
Why is the sitemap line skip when my sitemap exists?
It isn't declared in robots.txt (Sitemap:) and isn't at /sitemap.xml on the same origin. Declare it in robots.txt: that's where engines look for it too.
GET /api/v1/projects
The reference of the read routes — your projects, a project's detail, its open incidents, its last deploy, its production history, and your account.
6 routes, all read-only, all JSON, to read the state of your projects from your own tools: an internal dashboard, a Monday morning script, a bot. None writes anything, and there is no route that does. None consumes your monthly check quota. The token is the API's, in an Authorization: Bearer header.
GET /api/v1/projects
What this token is allowed to see: the projects owned by the account and those where it's an accepted member, in creation order.
export POSTSHIP_TOKEN=psk_…
curl -sS https://postship.fr/api/v1/projects \
-H "Authorization: Bearer $POSTSHIP_TOKEN"{
"projects": [
{
"id": "3f1c…",
"name": "Shop",
"url": "https://shop.com",
"status": "pass",
"lastCheckedAt": "2026-09-11T08:42:10.000Z",
"paused": false
}
]
}| Field | Type | What it contains |
|---|---|---|
id | string | The project's id, to pass to the 2 following routes |
name | string | The project's name |
url | string | The declared production address |
status | "pass", "fail" or null | The last summarised state: fail as soon as an active target is in fail or error; null before the first run |
lastCheckedAt | string or null | The date of the last complete run, ISO 8601 |
paused | boolean | The project is paused: nothing runs |
An account without a project receives {"projects":[]}. Columns are named one by one server-side: a select("*") would leak the webhook secrets and the host tokens the day someone adds a column, without anyone having decided anything.
GET /api/v1/projects/{id}/incidents
What is down right now, and since when. These are incidents in the application's sense — active targets whose last verdict is fail or error — and not the incidents published on the status page, which are a narrative chosen by the owner. Both bear the same name and don't say the same thing; this one is the raw fact.
curl -sS https://postship.fr/api/v1/projects/3f1c…/incidents \
-H "Authorization: Bearer $POSTSHIP_TOKEN"{
"incidents": [
{ "url": "https://shop.com/checkout", "kind": "http", "outcome": "fail", "since": "2026-09-11T08:40:02.000Z" },
{ "url": "https://shop.com", "kind": "securite", "outcome": "fail", "since": "2026-09-10T17:12:44.000Z" }
]
}| Field | Type | What it contains |
|---|---|---|
url | string | The target's URL (for a site check, the project's base URL) |
kind | string | The kind of check: http, og, sitemap, ssl, form, journey, api, heartbeat, and the site checks (securite, cookies, exposition, certificats, integrite…) |
outcome | "fail" or "error" | fail: the site answered wrong; error: PostShip couldn't conclude |
since | string | The start of this target's last run, ISO 8601 |
A disabled target keeps its last verdict forever: it's a trace, not an outage, and it doesn't appear here. A project with nothing failing returns {"incidents":[]}.
GET /api/v1/projects/{id}/last-ship
The last production deploy, and not the last one full stop: a preview checked 2 minutes ago says nothing about what's live, and an integration that showed its score as the site's would be wrong without ever knowing it. Same rule as the Overview and the weekly digest.
curl -sS https://postship.fr/api/v1/projects/3f1c…/last-ship \
-H "Authorization: Bearer $POSTSHIP_TOKEN"{
"lastShip": {
"provider": "vercel",
"sha": "a1b2c3d",
"at": "2026-09-11T08:39:51.000Z",
"outcome": "pass",
"failedChecks": 0,
"score": 100,
"scoreReason": null
}
}| Field | Type | What it contains |
|---|---|---|
provider | string | The deploy's origin: vercel, netlify, cloudflare, generic (the generic webhook) or empreinte (the detection without webhook) |
sha | string or null | The commit, when the host passed it on |
at | string | The deploy's start, ISO 8601 |
outcome | string | The verdict of the check that followed |
failedChecks | number | The number of failing checks |
score | number or null | The Ship Score, null as long as grading hasn't happened |
scoreReason | string or null | The main deduction, null when everything passed |
A project without a tracked production deploy returns {"lastShip":null} — not a 404, which would mean “project not found”.
GET /api/v1/me
The plan, this month's check quota, and the token that is speaking. Nothing of the account itself: no email address, no name.
curl -sS https://postship.fr/api/v1/me \
-H "Authorization: Bearer $POSTSHIP_TOKEN"{
"plan": "pro",
"quota": { "used": 12, "limit": 500, "remaining": 488 },
"token": { "prefix": "psk_ab12cd", "name": "Site CI", "lastUsedAt": "2026-09-16T08:42:00.000Z" }
}GET /api/v1/projects/{id}
A project's detail: its monitored URLs, its status page, whether it is paused.
curl -sS https://postship.fr/api/v1/projects/3f1c… \
-H "Authorization: Bearer $POSTSHIP_TOKEN"{
"project": {
"id": "3f1c…",
"name": "Shop",
"url": "https://shop.example",
"status": "pass",
"lastCheckedAt": "2026-09-16T08:42:00.000Z",
"paused": false,
"createdAt": "2026-06-01T10:00:00.000Z",
"statusPage": "shop",
"urls": [
{ "id": "…", "url": "https://shop.example/checkout", "kind": "http", "enabled": true, "outcome": "pass", "lastCheckedAt": "2026-09-16T08:42:00.000Z" }
]
}
}statusPage is the published page's slug (/s/<slug>), null without a page. Another account's project returns the same 404 as a non-existent one.
GET /api/v1/projects/{id}/ships
The history of production deployments, most recent first — not the previews. ?limit=20 by default, 100 at most.
curl -sS "https://postship.fr/api/v1/projects/3f1c…/ships?limit=20" \
-H "Authorization: Bearer $POSTSHIP_TOKEN"{ "ships": [ { "id": "…", "provider": "vercel", "sha": "a1b2c3d", "at": "2026-09-11T08:39:51.000Z", "outcome": "pass", "failedChecks": 0, "score": 100, "scoreReason": null } ] }The fields are those of last-ship, plus id.
Codes and errors
| Code | When |
|---|---|
401 | Invalid or revoked key |
404 | Unknown project, or one this token isn't allowed to see — same response in both cases |
Responses carry Cache-Control: no-store. No pagination: an account owns 10 projects at most, and a project 50 URLs at most.
Limits and plans
No limit of its own: reads aren't counted. The number of visible projects and URLs is the plan's (see the plans).
Troubleshooting
Why is status fail when all my URLs are green?
The site checks count too: a check in error (certificate directory unreachable, page in timeout) is enough. The incidents route says which one.
Why is lastShip null when I deploy?
No production deploy was received: neither webhook, nor fingerprint detection. Previews don't count here.
Why don't the API's incidents match my status page?
They aren't the same objects. The API returns the targets failing now; the status page shows the incidents you published, with a summary and a state. An incident published then resolved stays on the page, not in the API.
The 6 checks of a URL
What the audit of a single URL looks at — the one behind the API and postship check —, and how it grades it.
A single URL, without a project: that's what the API and the postship check command check. 6 lines and a Ship Score, computed by the same functions as the check cycle and graded like a real deploy.
What is checked
6 checks, launched in parallel under a shared budget of 40 requests to the targeted site — the shape of a real run, and the reason why 6 checks answer in the time of the slowest rather than the sum of the 6.
| Line | What is looked at | What the detail says |
|---|---|---|
| Page and resources | The page answers 200, and its declared JS and CSS files exist (HTTP and assets) | “Status 200 · 412 ms”, or “3 resources declared but missing” |
| Indexability | robots.txt, meta robots, X-Robots-Tag, canonical (Indexability) | “Crawlable, consistent canonical”, or the blocking codes |
| AI visibility | What robots.txt allows to answer engines' robots, and the text present without JavaScript (AI visibility) | “ChatGPT can't read you”, “312 characters only without JavaScript”, “Quoting forbidden (nosnippet)” |
| Social card | The Open Graph tags and the share image (Open Graph) | “Valid title and image”, or “Missing: og:image” |
| Sitemap | The sitemap declared in robots.txt (Sitemap:), otherwise /sitemap.xml (Sitemap and SSL) | “48 URLs declared”, “48 URLs declared, 2 unreachable”, “No sitemap declared” |
| SSL certificate | The served certificate and its end date | “61 days before expiry” |
The sitemap is looked for where the answer is written — robots.txt — before falling back on /sitemap.xml. Guessing the root path would be wrong on every site that declares it elsewhere, and an audit that defames the site it looks at is worse than no audit. A site without a sitemap (404) is marked “skip”, not failing: having nothing to check isn't a fault, and the line doesn't weigh on the grade. The declared sitemap must be on the same origin: a robots.txt pointing elsewhere isn't a licence to go fetch a third party.
No “payment pages” assertion here: we don't know which of a stranger's pages takes payments, and guessing would apply the scale's heaviest penalty to a page that has never seen a payment.
The grade
The Ship Score: 100 minus the deductions of the failing lines, with the main reason (“−25 missing asset”, “−30 non-indexable site”). The response also carries outcome (pass if no line is in fail or error) and the number of failing lines.
Going further
A PostShip project replays these 6 checks on all your URLs on every deploy, adds the site checks, and only writes to you when one turns red. From your CI, the same audit runs with an API key, through the API or postship check; it counts in the month's check quota.
Troubleshooting
Why is my sitemap “No sitemap declared” when it exists?
It's neither declared in your robots.txt by a Sitemap: line, nor at /sitemap.xml, nor on the same origin. Declare it in robots.txt: that's also where engines look for it.
Why does the grade differ from my project's?
The audit knows neither your payment pages, nor your thresholds, nor your journey: it grades a single URL, without assertions. In a project, the payment pages and the site checks weigh in addition.
Exports
The files you can download from a project — incidents, journal and performance as CSV, last deploy as JSON — and the exact format of each.
A client asks, at the end of the quarter, what happened; a contractor disputes that “it's slower since the redesign”; a report needs an attachment. The answer must not be a screenshot of a dashboard. Every project page that carries a story has its export: a file, in a format a spreadsheet or a script reads without surprise.
What PostShip exports
| Export | Where | Format | Window |
|---|---|---|---|
| The incidents | Project → Incidents → “Export the incidents (CSV)” | CSV | The plan's retention |
| The journal | Project → Monitoring → Log → “CSV” | CSV | Everything the database contains, 2,500 rows at most |
| Performance per URL | Project → Monitoring → Performance → “CSV” | CSV | The plan's retention |
| The last deploy | Project → Deployments → “Export the last deploy (JSON)” | JSON | The last production deploy |
| The account | Account → Summary → “Export” | JSON | Profile, projects, monitored URLs |
Exports are direct links to /api/projects/<id>/export/…: they go through the same authorisation as the page (a project you aren't allowed to see answers “not found”) and require the second factor when the account has enabled it — the file leaves as the document you attach to a client report, it deserves the same door as the dashboard.
The incidents (CSV)
One row per incident over the kept window, from most recent to oldest: debut, fin (empty if ongoing), etat (investigating, identified, resolved), publie (oui / non), resume. These are the project's incidents, published or not — not only those told on the status page. The window is the plan's, not 90 days for everyone: exporting beyond what it keeps would make a file empty at its end, which reads as “no incident” rather than “no data”.
The journal (CSV)
What the Log page shows, without the display cap: date, genre, titre, detail, acteur. The kinds: deploiement, incident, alerte, equipe, note — up to 500 events per source, 2,500 in total. The domain (DNS, certificates) isn't in it: its events are computed by comparing readings, and the export must stay what the database contains, not what we deduce from it.
Performance (CSV)
One row per URL of type http, form or journey, sorted from slowest to fastest: url, type, mesures (the number of runs), mediane_ms, p95_ms, max_ms, depuis, jusqua. The measured time is the Performance page's, over the plan's window.
The last deploy (JSON)
The piece you attach to a report: the score, what was checked, what failed. Production, never a preview — a document that claims to describe what's live must not describe a branch.
{
"projet": "Shop",
"adresse": "https://shop.com",
"deploiement": {
"hebergeur": "vercel",
"commit": "a1b2c3d",
"date": "2026-09-11T08:39:51.000Z",
"verdict": "pass",
"verificationsEnEchec": 0,
"score": 100,
"raison": null
},
"urls": [{ "url": "https://shop.com/", "verdict": "pass" }],
"exporteLe": "2026-09-11T09:00:00.000Z"
}A project without a tracked production deploy still receives a document — deploiement: null and a message saying so — rather than a 404: the browser doesn't show the body of an error on a file link, and the download would fail silently.
The CSV format
Made so that Excel, Numbers, LibreOffice or pandas read it without a setting:
- UTF-8 with BOM: without it, Excel reads a UTF-8 file as latin-1 and shows “résolu”. The file would be right, the spreadsheet would read it wrong, and we'd be the ones blamed.
- CRLF line endings, as the standard expects.
- Every cell in quotes, inner quotes doubled: an incident summary contains commas, quotes and line breaks, and it's the undoubled quote that breaks a file in the middle of a sentence.
- Dates in ISO 8601 (UTC), durations in milliseconds.
- File name:
postship-incidents-2026-09-11.csv,postship-journal-…,postship-performance-…,postship-ship-2026-09-11.json. Cache-Control: no-store: every download is fresh.
What triggers an alert
Nothing: exports read. What they contain comes from the incidents, the alerts, the deploys and the runs.
Limits and plans
| Discover | Pro | Pro Plus | |
|---|---|---|---|
| The 5 exports | Yes | Yes | Yes |
| Incidents and performance window | 7 days | 30 days | 90 days |
The window is the one of the retention. The journal exports everything still in the database. See the plans.
Troubleshooting
Why does the download answer “Two-factor authentication required”?
Your account has a second factor and this session hasn't validated it yet. Sign in again validating the second factor, then start the download again.
Why does Excel show everything in a single column?
Some localised versions of Excel expect a semicolon. Use “Data → From Text/CSV” and choose the comma as separator; the file itself is standard.
Why is the incidents export empty when the journal shows some?
The journal exports everything in the database; the incidents export is limited to the plan's window. An incident older than the window may still appear in the journal, no longer in the incidents export.