Skip to content
Back to News
Changelog

SDK 4.0.0 and CLI 0.7.0 — the status you read was measured, not invented

sdkapibugfix

invoices.getStatus() has been answering "submitted" for every invoice, delivered ones included. waitFor() and getpeppr send --watch have been expiring on documents that arrived long before. Neither was a network problem, and neither was your integration: two separate defects lined up, one on each side of the wire.

This release closes both. It is a major version because the SDK now refuses work it used to fake.

The gateway was serving the wrong object

GET /v1/invoices/{id} read Storecove's sending evidence endpoint and passed the result through a mapper written for a document. The two shapes have almost nothing in common, so nearly every field fell on the floor and you received {"id": "...", "isCreditNote": false} — no status, no timestamps, nothing about the send.

The detail endpoint now projects the same record the list endpoint does, from our own database, where the status has been all along. It carries the status, the timestamps, and the recipient country.

The SDK filled the gap with a guess

When a response carried no status, the SDK substituted "submitted". That value is not a terminal state, so unless submitted was itself what you were waiting for, a polling loop could do nothing with it but expire — 120 seconds in waitFor(), 60 in --watch. The invoice was fine. The wait was not.

The SDK no longer supplies a status when the gateway sent none. A 2xx response missing one now raises PeppolProtocolError. A fabricated status is indistinguishable from a measured one for anyone reading status; an error is loud and reaches the party who can fix it.

A status the gateway did send but the SDK does not recognise is a different case and still behaves as before: status becomes "unknown" and rawStatus keeps the value verbatim. That is a stated inability to map, not an invention — "unknown" is not a state anything will act on by mistake.

import { PeppolProtocolError } from "@getpeppr/sdk";

try {
  const invoice = await peppol.invoices.getStatus(id);
} catch (error) {
  if (error instanceof PeppolProtocolError) {
    console.error(`Missing "${error.field}"`);
    console.error(error.responseBody);  // truncated past 2000 chars
  }
}

Your payload does not cause it, so retrying in a tight loop is the wrong response — though the error is not sticky either: a later call against the same document can succeed. If you see one, report it.

responseBody is the offending payload re-serialised, not the bytes as they arrived, and it is cut off past 2000 characters with a marker. Usually that is your own document data, so treat it like any other payload before putting it somewhere it will be retained. Sometimes it is not a document at all — a body of null reaches you as the string "null".

Two more guesses are gone with it. createdAt used to fall back to new Date().toISOString() — the moment of the call, presented as the moment of creation — and then to updatedAt, a different measurement wearing the same name.

Breaking changes

Identifiers now say which one they are

invoiceId meant two different things depending on where you read it: the webhook field carried our internal record id, while the query parameter filtered on the provider's GUID. One of them resolved against the API and one did not, and nothing in the name told you which.

Both now travel under explicit names on the send response, the list, the detail and the webhooks:

The detail endpoint accepts either of them, so an id you read from the list or from a webhook now resolves. That was the actual bug: our own list handed out an id our own detail could not look up.

Read submissionId defensively all the same. Writing our record is best-effort by contract, so a 201 omits the field rather than inventing one when that write did not happen. And a webhook replayed from a queue filled before this release carries the payload as it was serialised then, without submissionId — we can add a field to what we emit from now on, not to what is already sitting in an outbox. That is why it is not in the schema's required list: a contract we cannot keep is not a contract.

id is unchanged and keeps working; it is marked deprecated in the schema, with its successor named. No retirement date is set, and none is implied.

peppolMessageId, at last

The Peppol AS4 message id appeared in our public documentation and was served nowhere. It lives in the sending evidence, which is a separate read against the provider, so it is opt-in:

GET /v1/invoices/{id}?include=evidence

Ask for it when you need it. Each call is a live read, and it has no rate limit of its own beyond your tier's.

A note on /as/original

It answers 404 for a short window after a send: the archive it serves does not exist yet. The two 404s are distinguishable — an id you do not own or that does not exist returns Invoice not found, while a document we know about but cannot yet fetch says so in its message. Read the body before concluding anything.

We have not measured that window and will not publish a number for it, so do not hard-code one. Where we have measured Storecove timings, identical documents spread across an enormous range — which is reason enough not to hand you a figure for a different step and let it read like a guarantee. Treat the first 404 as "not yet" rather than "not there": retry with backoff, or wait for the webhook.

⚠️ And do not read a 404 here as "the transmission has not gone out". The two are unrelated: the archived document appears well before any access point signs for it. This endpoint answers a question about the archive, not about the network.

Upgrading

npm install @getpeppr/sdk@4.0.0
npm install -g @getpeppr/cli@0.7.0

The CLI bundles the SDK, so send --watch only gets this fix at 0.7.0 — the version number is not cosmetic.

If you were working around the empty detail response by fetching /as/original and parsing the UBL, you no longer need to.

One thing this release did not do

The 3.2.0 note said VAT categories L and M would be removed from the vatCategory type in the next major release. This is that release, and they are still there. They remain unroutable and are still refused with a 422 unsupported_vat_category — nothing about their behaviour changed — but the type was left alone rather than bundled into an unrelated breaking change. Treat the removal as pending, not as done.