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
- What PostShip checks
- What the schema keeps
- Breaking or informative
- Set it up
- What triggers an alert
- Limits and plans
- Troubleshooting
- Why is an optional property flagged “is no longer always present”?
- Why is the run in error rather than in failure?
- The API returns JSON but with Content-Type: text/html
- Scheduled task (heartbeat)
- What PostShip does
- Set it up
- What triggers an alert
- Limits and plans
- Troubleshooting
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:
- 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”.
- 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.
- 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.
- 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
| Drift | Severity | Sentence |
|---|---|---|
type_change | Breaking | $.price: number → string |
prop_disparue | Breaking | $.items disappeared |
requis_absent | Breaking | $.items[].sku is no longer always present |
format_perdu | Breaking | $.created_at is no longer a date-time |
prop_nouvelle | Informative | $.currency: new property |
enum_nouvelle | Informative | $.status: new value "refunded" |
tableau_vide | Informative | $.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
| Code | Sentence |
|---|---|
statut:200:503 | The API answers 503 instead of 200. |
json_invalide | The response is no longer valid JSON. |
contrat:type_change:$.price | API contract: $.price changed type. |
contrat:prop_disparue:$.items | API contract: $.items disappeared. |
contrat:requis_absent:$.items[].sku | API contract: $.items[].sku is no longer always present. |
contrat:format_perdu:$.created_at | API 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
GETorPOST, indifferently — acurlat the end of a script is aGET, a tool's webhook is aPOST; - 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”:
- What is it called? “nightly backup”, 80 characters at most.
- 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.
- “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
curlline ready to paste.
Then, at the end of your 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
| Code | Screen sentence | Alert sentence |
|---|---|---|
heartbeat_late | The script didn't call PostShip within the expected delay. | The heartbeat is no longer received — nightly backup |
heartbeat_never | The 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.
| Interval | Margin | Alert at the earliest after the last call |
|---|---|---|
| 5 min | 1 min 15 | 6 min 15 |
| 1 h | 15 min | 1 h 15 |
| 6 h | 1 h | 7 h |
| 1 day | 1 h | 25 h |
| 1 week | 1 h | 7 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.