Developers

Announce stock into the warehouse from your own software

One POST tells us what’s on its way. From there it’s the same pipeline as the portal’s own Add stock form - we check it against what’s already announced, verify every SKU against the client’s Seller Central, and put the line in front of the warehouse. Read endpoints tell you how far along it got.

Tokens are issued by the account holder in Portal → Settings → API. Each one belongs to exactly one Oakmont account and can be revoked from the same page.

Quickstart

Base URL https://oakmontlogistics.co.uk/api/v1. JSON in, JSON out, bearer token on every request.

curl -X POST 'https://oakmontlogistics.co.uk/api/v1/inbound' \
  -H 'Authorization: Bearer oak_live_YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: order-10482' \
  -d '{
    "lines": [
      {
        "asin": "B0CX41NQ2P",
        "quantity": 24,
        "cost": 9.66,
        "sku": "HN-MAG500-24",
        "supplier": "Halden Nutra",
        "orderRef": "PO-10482",
        "purchaseDate": "2026-08-14"
      }
    ]
  }'
{
  "success": true,
  "data": {
    "announced": 1,
    "lines": [
      {
        "id": "ASN-260814-7F3K2P",
        "sku": "HN-MAG500-24",
        "asin": "B0CX41NQ2P",
        "title": "Magnesium Glycinate 500mg - 120 caps",
        "quantity": 24,
        "status": "Announced",
        "listedOnAmazon": true
      }
    ],
    "unlisted": [],
    "message": "1 line announced.",
    "warnings": []
  }
}

That’s the whole integration for most tools: post each purchase as it happens, keep the returned id, and poll GET /inbound when you want to show progress.

Authentication

Authorization: Bearer oak_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

A token identifies one Oakmont account. There is no account parameter on any endpoint and no way for a token to reach another client’s data - which is the reason it’s built this way rather than as one partner key with a customer reference.

  • Shown once, at creation. We store only a SHA-256 hash.
  • Revoking takes effect immediately, and the account holder can do it themselves without contacting us.
  • A paused Oakmont account stops authenticating. Every token on it returns UNAUTHORIZED until the account is active again.
  • Use separate tokens per environment, and keep them in a secret manager - a token is a write credential against a real warehouse.

CORS is open (Access-Control-Allow-Origin: *) and OPTIONS is handled on every route, so a browser can call this directly. It should not: doing so ships the token to everyone who loads the page. Call it from your server.

Responses & errors

Every response, success or failure, uses one envelope.

{ "success": true,  "data": { … } }
{ "success": false, "error": { "code": "VALIDATION_ERROR", "message": "…", "details": { … } } }
UNAUTHORIZED401Missing, malformed, revoked or unknown token - or the account is paused. Deliberately one message for all of them.
BAD_REQUEST400The body isn't JSON, or isn't a shape we recognise as lines. Nothing was validated because nothing parsed.
VALIDATION_ERROR422The request parsed and one or more fields are wrong. `details` is keyed by path - lines[3].asin - with every problem in the batch, not just the first.
NOT_CONFIGURED403The account hasn't finished setup, so announcing is held. Nothing the caller sends will fix it; the account holder finishes setup in the portal.
NOT_FOUND404No line with that id on this account.
CONFLICT409The batch looks like something already announced and not yet received. Nothing was written. See Idempotency & duplicates.
RATE_LIMITED429Too many requests. Retry-After says how long to wait.
INTERNAL_ERROR500Our side. Safe to retry a read; retry a write with the same Idempotency-Key.

SKUs - yours, kept as sent

Send a SKU and we write it exactly as given. No prefixing, no reformatting, no substitution. If the tool you’re building already numbers stock, that numbering is the thing your users reconcile against, and a SKU rewritten in transit is one that no longer matches their own records.

The only checks are the ones Amazon imposes: 40 characters or fewer, starting with a letter or digit, using letters, digits and . _ - /. Anything else is refused with a VALIDATION_ERROR rather than silently stripped - a Seller Central SKU cannot be renamed once its listing exists, so a quietly-altered one is permanent.

Leave the field out and Oakmont generates one from the account’s own SKU format (supplier, cost, quantity, ASIN, date - configured in the portal). The generated value comes back on the response, so you always learn what the stock is called. If the account has auto-generation switched off, a line without a SKU is refused. GET /account tells you which mode the account is in before you build the payload.

Idempotency & duplicates

Send an Idempotency-Key header (or an idempotencyKey field) on every announce. Any string works - an order id, a message id, a UUID. A repeat within 10 minutes returns the original response instead of announcing the batch twice. This matters more here than in most APIs: the write appends rows to a live warehouse sheet and is never retried automatically, precisely because a retry after a lost response would duplicate a delivery.

Separately, if a batch looks like something already announced and not yet received, the request returns 409 CONFLICT and nothing is written. Two purchase orders for the same product is a real thing, so this is a question rather than a rule - resend with "confirmDuplicates": true to go ahead. The lines it recognised are in error.details.duplicates, so you can show your user what was matched.

Announce inbound stock

POST/api/v1/inbound

Body: { "lines": [ … ] }, up to 200 lines. A bare array or a single line object are also accepted. Field names are matched loosely - costPrice, unit_cost and cost are the same field - but sending two spellings of one field in the same object is an error rather than a coin toss.

asinstringrequired10 letters or digits. The only thing that ties a line to a product; an EAN here books stock against something that doesn't exist, so the shape is checked.
quantityintegerrequiredUnits on the way, 1 or more. Aliases: qty, units, qtyExpected.
costnumberrequiredWhat one unit cost, in GBP. Aliases: costPrice, unitCost, buyPrice. Kept private to the account - it's never written to the warehouse sheet.
currencystringDefaults to GBP. Set it to anything else and you must also send costGbp - we don't invent an FX rate for somebody's landed cost.
costGbpnumberThe GBP figure when currency isn't GBP. Alias: costPriceGbp.
skustringOptional. Used verbatim - see SKUs above. Omit it to have one generated.
titlestringOptional. Left out, we fill it from Amazon product data.
supplierstringWho it was bought from. Aliases: source, merchant, vendor. Feeds the {MERCHANT} part of a generated SKU.
orderRefstringSupplier order number and/or inbound tracking. Aliases: sourceOrderRef, poNumber, reference.
notesstringAnything the warehouse needs to know: bundles, fragile, expiry dates, poly bag. Aliases: instructions, specialInstructions.
purchaseDatestring | numberWhen it was bought. YYYY-MM-DD, an ISO timestamp, or epoch milliseconds. Defaults to today; more than a year old or in the future falls back to today.

Top-level, alongside lines: idempotencyKey and confirmDuplicates.

Response 201: announced (count), lines (id, sku, asin, title, quantity, status, listedOnAmazon), unlisted (SKUs Amazon doesn’t hold yet - the portal creates those listings itself a few minutes later when auto-listing is on), and warnings.

List what you've announced

GET/api/v1/inbound

curl 'https://oakmontlogistics.co.uk/api/v1/inbound?status=Received&limit=50' \
  -H 'Authorization: Bearer oak_live_YOUR_TOKEN'
statusstringAnnounced, Part-received, Received, Part-shipped, Shipped, Issue, Closed.
skustringExact match.
asinstringExact match.
since / untilYYYY-MM-DDInclusive, on purchase date.
page / limitintegerDefaults 1 and 25; limit caps at 100.

Returns { lines, total, page, totalPages, limit }, newest purchase first. Each line carries the counts that matter to a progress display:

{
  "id": "ASN-260814-7F3K2P",
  "sku": "HN-MAG500-24",
  "asin": "B0CX41NQ2P",
  "title": "Magnesium Glycinate 500mg - 120 caps",
  "supplier": "Halden Nutra",
  "orderRef": "PO-10482",
  "notes": "",
  "purchaseDate": "2026-08-14",
  "quantity": 24,
  "received": 24,
  "prepped": 24,
  "shipped": 12,
  "inWarehouse": 12,
  "outstanding": 0,
  "status": "Part-shipped",
  "listedOnAmazon": true,
  "shipmentRef": "FBA15ABC1234",
  "receivedDate": "2026-08-19",
  "shippedDate": "2026-08-22",
  "updatedAt": "2026-08-22T10:14:02.000Z"
}

prepped is null on lines that predate prep tracking - that means “not tracked”, not “none prepped”. Reads are served from a cache up to a minute old - the same one the client’s own portal pages use, so a poller can’t crowd out the warehouse.

Fetch one line

GET/api/v1/inbound/{id}

Takes the id from a create or list response and returns the same object. Unknown id returns 404 NOT_FOUND. A write is visible here within a minute; the response to the create itself is immediate and authoritative.

Check a token

GET/api/v1/account

Call this when a user pastes a token into your settings screen: it confirms the token is live, names the account it belongs to, and tells you how to build the payload.

{
  "success": true,
  "data": {
    "clientId": "OAK-004",
    "company": "Halden Nutra Ltd",
    "plan": "Default",
    "canAnnounce": true,
    "amazonConnected": true,
    "sku": { "autoGenerate": true, "format": "{MERCHANT}_{COST}_x{QTY}_{ASIN4}_{MON}_{YY}" },
    "token": { "id": "9f2c1a4b", "label": "Sourcery", "created": "2026-08-17T09:12:00.000Z" }
  }
}

canAnnounce: false means the account hasn’t finished setup and every announce will return NOT_CONFIGURED. Worth surfacing to your user in your own words rather than relaying ours.

Rate limits

  • 120 requests per minute per token.
  • 10 announces per minute per account, shared with the portal’s own form - the limit protects the account’s spreadsheet, so it doesn’t double because you came through a different door. Batch up to 200 lines per call.
  • 300 requests per minute per caller before authentication.

Authenticated responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset so you can pace yourself instead of finding the ceiling.

What this API doesn't do yet

There is no update or delete: a line is amended or cancelled from the portal, where the client can see what else it affects. Inventory levels, shipments and billing are read in the portal too. If your integration needs one of those, tell us what you’re building - the surface is small on purpose, and it grows towards whatever is actually being asked for.