Skip to content

Get started

Most read

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

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

MethodRouteWhat it doesQuota
POST/api/v1/checkChecks a URL now and returns the Ship Score (reference)Counted
GET/api/v1/projectsYour projects: id, name, address, last state (reference)Discover
GET/api/v1/projects/{id}/incidentsThe URLs failing right now, and since whenDiscover
GET/api/v1/projects/{id}/last-shipThe last production deploy, with its scoreDiscover

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

CodeWhenBody
200Everything went wellThe resource
400URL refused on /check: not https, credentials in the URL, private or unresolvable host{"error":"…"} with the exact reason
401Key missing, malformed, unknown or revoked{"error":"Invalid or revoked API key."}
404Unknown project — or one this token isn't allowed to see{"error":"Project not found."}
429Monthly 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.

DiscoverProPro Plus
Checks per month305003000
Reads (projects, incidents, last-ship)UnlimitedUnlimitedUnlimited
Active tokens555

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

MethodPOST
HeadersAuthorization: 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.

From a terminal
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 }
}
FieldTypeWhat it contains
urlstringThe checked URL, normalised
scorenumberThe Ship Score, from 0 to 100, computed by the function that grades a real deploy
reasonstring or nullThe 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
failednumberThe number of lines in fail or error
checks[]arrayOne line per check (see below)
checks[].kindstringhttp, index, og, sitemap, ssl, ia
checks[].labelstringThe readable name: Page and resources, Indexability, Social card, Sitemap, SSL certificate, AI visibility
checks[].outcomestringpass, fail, error (PostShip couldn't conclude: timeout, DNS), or skip (nothing to check — a site without a sitemap)
checks[].detailstringA sentence, the one a person would say: “Status 200 · 412 ms”, “ChatGPT can't read you”
quota.usednumberThe checks consumed this month, this one included
quota.limitnumberThe plan's cap: 30, 500 or 3000
quota.remainingnumberWhat'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

CodeWhenBody
200The check happened, whatever its verdictThe object above
400Unreadable body or without url{"error":"Invalid URL (https required)."}
400Refused URL{"error":"The URL must be https."}, {"error":"Invalid URL (credentials not allowed)."}, or the network guard's reason
401Invalid or revoked key{"error":"Invalid or revoked API key."}
429Month'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

DiscoverProPro Plus
Checks per month305003000

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
    }
  ]
}
FieldTypeWhat it contains
idstringThe project's id, to pass to the 2 following routes
namestringThe project's name
urlstringThe declared production address
status"pass", "fail" or nullThe last summarised state: fail as soon as an active target is in fail or error; null before the first run
lastCheckedAtstring or nullThe date of the last complete run, ISO 8601
pausedbooleanThe 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" }
  ]
}
FieldTypeWhat it contains
urlstringThe target's URL (for a site check, the project's base URL)
kindstringThe 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
sincestringThe 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
  }
}
FieldTypeWhat it contains
providerstringThe deploy's origin: vercel, netlify, cloudflare, generic (the generic webhook) or empreinte (the detection without webhook)
shastring or nullThe commit, when the host passed it on
atstringThe deploy's start, ISO 8601
outcomestringThe verdict of the check that followed
failedChecksnumberThe number of failing checks
scorenumber or nullThe Ship Score, null as long as grading hasn't happened
scoreReasonstring or nullThe 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

CodeWhen
401Invalid or revoked key
404Unknown 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.

LineWhat is looked atWhat the detail says
Page and resourcesThe page answers 200, and its declared JS and CSS files exist (HTTP and assets)“Status 200 · 412 ms”, or “3 resources declared but missing”
Indexabilityrobots.txt, meta robots, X-Robots-Tag, canonical (Indexability)“Crawlable, consistent canonical”, or the blocking codes
AI visibilityWhat 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 cardThe Open Graph tags and the share image (Open Graph)“Valid title and image”, or “Missing: og:image”
SitemapThe sitemap declared in robots.txt (Sitemap:), otherwise /sitemap.xml (Sitemap and SSL)“48 URLs declared”, “48 URLs declared, 2 unreachable”, “No sitemap declared”
SSL certificateThe 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

ExportWhereFormatWindow
The incidentsProject → Incidents → “Export the incidents (CSV)”CSVThe plan's retention
The journalProject → Monitoring → Log → “CSV”CSVEverything the database contains, 2,500 rows at most
Performance per URLProject → Monitoring → Performance → “CSV”CSVThe plan's retention
The last deployProject → Deployments → “Export the last deploy (JSON)”JSONThe last production deploy
The accountAccount → Summary → “Export”JSONProfile, 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

DiscoverProPro Plus
The 5 exportsYesYesYes
Incidents and performance window7 days30 days90 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.