Skip to content

Get started

Most read

API contract

PostShip learns the shape of a JSON response over 3 runs, then alerts as soon as it drifts.

Updated on 25 September 2026

On this page

An API that answers 200 can break your clients: price becomes a string, items disappears, a date is no longer one. Nothing flags it, until the mobile app crashes or a partner's integration stops. The API contract requests a URL that returns JSON, learns its shape, then compares each run with what it learnt. You write no schema.

What PostShip checks

On every run, PostShip does a GET with Accept: application/json (12 seconds, 5 redirects, private header possible) and proceeds in order:

  1. The status. It must be exactly the expected status (200 by default). Otherwise, outright failure, before even reading the body: the contract starts with “responding”.
  2. The JSON. The body must be valid JSON and fit in 512 KB — beyond that, PostShip considers it's reading an export rather than a contract, and the run is in error.
  3. Learning. The first 3 runs feed the schema without ever judging. A single sample doesn't say what is optional nor what is an enumeration; 2, barely more.
  4. The comparison. From the fourth run, the response is compared with the learnt schema. Each gap is a drift, breaking or informative.

What the schema keeps

For each value: its type (null, boolean, number — integer or not —, string, array, object), the alternative types seen at the same place (string | null), and for strings a recognised format (date, datetime, url, email, uuid) or the distinct values seen as long as they are few (8 at most, of 64 characters at most) — that's how a status becomes a paid | pending enumeration. A property present in all samples is required; present in some, optional. Beyond 12 levels of nesting or 200 keys in an object, the container is truncated: it's an indexed dictionary, not a structure.

Breaking or informative

DriftSeveritySentence
type_changeBreaking$.price: number → string
prop_disparueBreaking$.items disappeared
requis_absentBreaking$.items[].sku is no longer always present
format_perduBreaking$.created_at is no longer a date-time
prop_nouvelleInformative$.currency: new property
enum_nouvelleInformative$.status: new value "refunded"
tableau_videInformative$.items is empty

Breaking: a typed client doesn't recover from it. Informative: nothing breaks, but the contract moves. Informative drifts are learnt silently — the new property is now part of the contract and won't be flagged again. The schema therefore keeps growing with the API, and “breaking” keeps the meaning you expect: something your code wouldn't have planned for.

Set it up

Project → URLs → “Monitor…” → “An API”. The “What we expect from the API” screen asks for the expected status and, for a private API, an access header (Authorization, X-Api-Key, X-Monitoring-Key or X-Health-Token) sent to the origin host only.

On the URL's detail, the “API contract” panel shows: during learning, “Learning in progress: 2 runs of 3. Nothing is judged before.”; afterwards, the learnt schema in plain words (6 levels shown at most), the breaking drifts in red, the informative ones in grey with the mention “learnt”, and the number of learning runs.

The “Re-learn the contract” button erases the schema. After a deliberate API change, that's what you want: 3 runs later, the new contract is the reference, without having to describe it.

What triggers an alert

CodeSentence
statut:200:503The API answers 503 instead of 200.
json_invalideThe response is no longer valid JSON.
contrat:type_change:$.priceAPI contract: $.price changed type.
contrat:prop_disparue:$.itemsAPI contract: $.items disappeared.
contrat:requis_absent:$.items[].skuAPI contract: $.items[].sku is no longer always present.
contrat:format_perdu:$.created_atAPI contract: $.created_at no longer has the expected format.

The alert quotes the first 2 breaking drifts and counts the others: “3 breaking changes: $.price: number → string, $.items disappeared and 1 other”. One code per drift, stable from one run to the next: the same drift doesn't alert on every cycle, an additional drift re-alerts. Informative drifts never alert. A failing contract costs 10 points on the Ship Score.

Limits and plans

Available on all plans; a target counts as one URL of the quota (Discover 5, Pro 25, Pro Plus 100). The rhythm is the cycle's — 5 minutes on Discover, 2 on Pro, 1 on Pro Plus (its 30-second probe only watches pages) — plus every deploy, and learning therefore takes from a few minutes to a quarter of an hour before the first verdict.

Troubleshooting

Why is an optional property flagged “is no longer always present”?

Because it was present in the 3 learning samples: PostShip learnt it as required. Click “Re-learn the contract” at a time when the response sometimes contains it, or accept the verdict as the signal a typed client would have had.

Why is the run in error rather than in failure?

An error means PostShip couldn't judge: timeout, response over 512 KB, redirect loop. A failure is a verdict on the contract. Both open an incident, but only the error says “The response exceeds 512 KB: too big for a contract.”.

The API returns JSON but with Content-Type: text/html

The announced type isn't checked: only the body counts. If JSON.parse succeeds, the contract is learnt.

Scheduled task (heartbeat)

Monitor what runs without a page — a cron, a worker, a backup — through the absence of a call.

Everything that runs without a page: a backup cron, a worker that drains a queue, a nightly billing script. No URL to check — and when it stops, nothing says so. The site answers 200, the backup hasn't run in 3 weeks. The heartbeat reverses the logic: it's your script that calls PostShip at the end of each run, and it's the absence of a call that triggers the alert.

What PostShip does

A heartbeat is a target without a URL to request. PostShip makes a secret address of the form https://postship.fr/api/heartbeat/<token>, and waits for it to be called:

  • as GET or POST, indifferently — a curl at the end of a script is a GET, a tool's webhook is a POST;
  • the body is ignored; all that counts is “we were called, and when”;
  • the response is OK, as text, status 200. An unknown or disabled token answers 404 “Unknown token.” — both answer the same, so that a disabled token doesn't reveal it existed.

On every project cycle (every 5 minutes on Discover, 2 on Pro, every minute on Pro Plus; the Pro Plus 30-second probe only watches pages), PostShip re-reads the date of the last call and compares it with the promised interval, with a margin of a quarter of the interval, capped at one hour: an hourly cron that calls at 10:00:00 then 11:00:04 must not alert at 11:00:01. Enough to absorb a slow start, not enough to let a missed execution through.

The first run. A heartbeat that has never been called yet isn't down — the script may not be deployed yet. It passes for a full interval (plus its margin) from its creation, then fails: after that delay, “never called” is a real silence.

The token is the only secret, and it's worth what it's worth: someone who has it can make it look like a cron runs. It's the model of Healthchecks.io and Cronitor, and it's acceptable because the worst effect is a silence — not access to anything.

Set it up

Project → URLs → “Monitor…” → “A scheduled task”:

  1. What is it called? “nightly backup”, 80 characters at most.
  2. How often should it run? Every 5 minutes, 15 minutes, every hour, every 6 hours, every day (default), every week. A list and not a free field: these 6 values cover real crons, and a 37-minute interval is almost always a typo.
  3. “Create the heartbeat”. The address to call is on the target's page, “Heartbeat” panel, with the expected delay, the last call, and 2 “Copy” buttons — the address alone, or the curl line ready to paste.

Then, at the end of your script:

At the end of the script
curl -fsS https://postship.fr/api/heartbeat/YOUR_TOKEN > /dev/null

-fsS: a failure of the call itself shows in the cron's logs, without noise when all is well. Call after the work, not before: a call at the start of the script would say “it runs” of a backup that fails on the second line.

Disabling a heartbeat (target menu) ignores the calls and sends no alert — for a maintenance, or a removed cron.

What triggers an alert

CodeScreen sentenceAlert sentence
heartbeat_lateThe script didn't call PostShip within the expected delay.The heartbeat is no longer received — nightly backup
heartbeat_neverThe script never called PostShip since this heartbeat was created.The heartbeat was never received — nightly backup

The run's detail gives the delay in minutes. The alert goes out at the crossing of the delay — after the project's confirmation, which counts in cycles here — and only once per silence; “Recovered” follows the next call received. A late heartbeat opens an incident like any target.

Limits and plans

Available on all plans. A heartbeat counts as one URL of the quota (Discover 5, Pro 25, Pro Plus 100): it's a target you ask for. The verdict is re-read on every project cycle: a silence is therefore observed with at most 5 minutes of delay on Discover, 2 minutes on Pro, 1 on Pro Plus — the Pro Plus 30-second probe only watches pages, not heartbeats.

IntervalMarginAlert at the earliest after the last call
5 min1 min 156 min 15
1 h15 min1 h 15
6 h1 h7 h
1 day1 h25 h
1 week1 h7 days and 1 h

Troubleshooting

Why didn't the alert go out when the cron skipped an execution?

Look at the margin: a cron every 5 minutes has 1 min 15 of margin, and the verdict is only re-read on the project's cycle — 2 minutes on Pro, 1 on Pro Plus, 5 on Discover; the Pro Plus 30-second probe changes nothing here. A 3-minute delay can therefore slip between 2 cycles, and on Discover a 5-minute heartbeat is only re-read once per period: prefer an interval of at least 15 minutes.

My script calls the address but the panel says “never”

The call didn't reach PostShip: curl without -f hides a 404, an empty environment variable gives a truncated address, an outbound firewall blocks postship.fr. Run the line by hand and read the response: it must be OK.

Can I change the interval?

Delete the heartbeat and recreate it with the right interval: the token changes, update your script. The old token answers 404 immediately.