Guide

API guide

Every endpoint and field lives in the generated reference. This page covers the handful of rules that a schema cannot state — and that an integration gets wrong.

Start here

If you want toGo to
Read every endpoint and fieldthe generated reference
Generate a client, or import into Postman/api/openapi/v1.json
See a first call end to endthe quickstart

The OpenAPI document is generated from the code rather than maintained separately, so it cannot drift from what the API actually does. Postman imports it directly — Import → Link — and gives you every request with its parameters already filled in.

No try-it console, deliberately

The reference renders the document and nothing more. A console that fires live calls from a documentation page would put them against a database shared with a working regulatory system.

Authentication

Two credentials, two audiences. They are never interchangeable.

CredentialHeaderUsed by
ptm_live_… / ptm_test_…X-Api-KeyPartner systems
Portal JWTAuthorization: Bearer …The web portal — read-only, and manages credentials

One key covers your whole account. If you operate an OMC and two depots, that is one credential, not three — and you never tell us which company is acting. The order already records its OMC, its BDC and its depot, so we read the actor off the work.

A key's permissions are your PetroMon licence types, and holding one lets the key do everything that licence may do:

text
OMC     raise orders, read yours
BDC     approve, decline or cancel orders, custody transfers
DEPOT   depot approval, vehicle checks, loading, marking, cancelling, custody transfers

The portal offers only the licences your account actually holds. Asking for one it does not hold is refused when the key is issued, rather than granted and quietly ignored.

Holding the licence is not enough on its own

It has to be this order's depot. A partner operating two depots can load at either, and at neither of anyone else's — the licence gets you as far as the stage, and the order decides which of your companies drives it.

Raising an order is the one call that can be ambiguous

There is no order yet to read the actor from. With one OMC nothing changes. With several, send omcCode; omitting it is 422 ambiguous_actor, which lists yours, rather than a guess that raises the order in the wrong company's name.

Why the portal can't drive stages

A portal session is read-only by design. Every stage transition requires an API key, so an action that moves fuel is always attributable to a named integration rather than a browser tab someone left open. For the same reason, a key can never mint another key — that needs a portal session, so a leaked credential cannot multiply.

Keys issued before the rename still work

Older keys start erd_test_ / erd_live_ rather than ptm_…. They keep working indefinitely — there is nothing to rotate and no deadline.

Rate limits

Limited per credential, not per connection — spreading calls across hosts doesn't buy headroom.

CallerLimitWindow
API key240 requests1 minute
Portal session (per user)600 requests1 minute
Unauthenticated (login, registration)30 requests1 minute

Honour Retry-After

Over the limit you get 429 with a Retry-After header. The gateway shares a database with a live regulatory system, and the limit exists so one partner's retry loop doesn't become everyone's outage — not to price access. Back off on the header rather than blindly.

Rules the schema can't state

Four things the reference will not tell you, each of which produces a working-looking integration that is quietly wrong.

1. Codes, never ids

PetroMon's internal ids are never accepted and never returned. Everything is named by a code resolved from /api/v1/reference — bdcCode, depotCode, productCode, outletCode, brvNumber. Nothing you store can be invalidated by a renumbering inside PetroMon. A code that matches nothing is 422 unknown_code, naming the field.

Orders included: a stage call names the order in the path and carries no id in its body. A decline or cancellation names its reason the same way, by reasonCode from /api/v1/reference/reasons.

Reference lists carry only what you can use: deactivated companies and inactive products are absent rather than flagged, so anything returned can be ordered.

2. Compare statuses as names, never numbers

Statuses arrive as names — ordered, bdc_approved, good_standing, depot_approved, brv_checked, loaded, marked, released.

The underlying integers are not ordered by progress

Cancellations sit numerically above released. Any ordering or range comparison on them eventually reads a cancelled order as delivered fuel. Compare the status name instead.

3. Each stage belongs to one party

The API refuses a stage your partner type does not drive, with actor_not_permitted. One integration cannot walk an order end to end.

StageDriven bystatus
Create the orderOMC—
Cancel the order, before BDC approvalOMCppmc_cancelled
BDC approvalBDCbdc_approved · bdc_declined · bdc_cancelled
Depot approvalDepotdepot_approved · depot_cancelled
Vehicle checkDepotbrv_checked · depot_cancelled
LoadingDepotloaded
TaggingMarking company, or the depottagged (the order stays at brv_checked)
MarkingMarking company, or the depot—
Custody transfersDepot only—

Stage requests carry a status name — the status the order should end up in. The gateway turns it into PetroMon's number, so you never handle one. A decline or cancellation needs a comment and a reasonCode from /api/v1/reference/reasons, ends the order for good, and gives back any stock the BDC's approval reserved. Any other name is invalid_status, refused before the order is read.

Every name the stage endpoints accept, and nothing else. Case does not matter:

statusSend it toWhat it does
bdc_approvedapproveBDC approves; the volume is reserved
bdc_declinedapproveBDC declines
bdc_cancelledapproveBDC cancels
depot_approveddepot-approveDepot manager approves
depot_cancelleddepot-approve, check-brvDepot cancels
brv_checkedcheck-brvVehicle checked
loadedloadLoading recorded
taggedtagTag recorded — the order does not move; it stays brv_checked
ppmc_cancelledcancelOMC cancels its own order, before the BDC approves

tagged is the one name that is not a status: tagging leaves the order where it is. PetroMon's PPMS cancellation, after loading, is deliberately not accepted — it would give back stock that has already left the depot.

Names in, names out

You send the same names every response speaks, and numbers are not accepted. The right name at the wrong time is a different refusal — invalid_stage_transition when approving, cancellation_not_allowed when declining — because the name was fine and the order was not where it needed to be.

Reason codes arrive with a PetroMon release

The reasons list carries only reasons with a code. Where PetroMon has not recorded codes yet it is empty, and an order there cannot be declined or cancelled through the API until they are added.

Everything above is live. Release is not Not yet live — there is no route for it, so it answers 404 and is absent from the OpenAPI document rather than published and broken. It computes UPPF levies against a price window; it is not a status change on its own.

4. The volumes have to agree

On a road movement the vehicle's capacity replaces the volume you send — it is not a suggestion. Two sums are then checked, and both must be exact:

text
delivery lines            = the vehicle's capacity
tanks assigned to an outlet = that outlet's line

Either mismatch is invalid_volume, and the message names the outlet and both figures. Tank numbers come from compartments on /api/v1/reference/brvs. A worked example, with codes that resolve today, is in docs/API-EXAMPLES.md.

Compartments decide what can be marked

Marking is recorded per tank, so an order raised without a compartment breakdown cannot be marked later. On a single-outlet order you may omit it and every tank is assigned to that outlet.

Error codes

Branch on errorCode; the message is for logs. Every error carries a requestId that traces the call end to end.

CodeMeaningHTTP
unauthorizedMissing or invalid credential401
ambiguous_actorYou hold several OMCs; name one with omcCode422
actor_not_permittedYou hold no licence that drives this stage, or none of your companies is this order's party403
not_a_partyYour company is not on this order403
order_not_foundNo such order, or outside your scope404
unknown_codeA code matched nothing; the field is named422
validation_failedMalformed request400
duplicate_order_numberAlready used by this OMC409
invalid_stage_transitionOrder is not at a valid prior stage409
invalid_statusstatus is not one this stage endpoint accepts422
cancellation_not_allowedThat decline or cancel code is not allowed from the order's status422
comment_requiredA decline or cancellation needs a comment422
reason_requiredA decline or cancellation needs a reasonCode422
product_not_foundThe order's product is no longer in PetroMon's product list, so it cannot move422
reason_not_foundreasonCode is not in /reference/reasons, or this environment has no reason codes yet422
concurrent_modificationSomeone else changed it first409
stock_lockdown_activeStock-taking in progress423
product_not_permittedOMC not authorised for this product403
invalid_volumeLines or tanks do not add up422
brv_in_transactionVehicle is already on a live order422
insufficient_markerThe can does not hold what was asked of it422
rate_limit_exceededToo many requests429

404 rather than 403

An order outside your scope returns 404, not 403 — otherwise the response would confirm that someone else's order exists.

Webhooks

One endpoint per environment, HTTPS only. Posting again re-points the existing one and leaves the secret alone — rotate to change it. Deleting disables rather than removes, so the delivery history survives.

Failed deliveries retry with backoff and dead-letter after eight attempts. A replay creates a new delivery rather than resetting the old one, so the record of what you were and weren't told stays intact.

Verify the signature before trusting a payload

Every delivery carries X-Petromon-Signature — HMAC-SHA256 of the raw body under your endpoint secret — and X-Petromon-Timestamp. Compare in constant time, and reject anything older than five minutes. An unverified webhook endpoint is a way for anyone who learns the URL to tell you fuel moved when it did not.