API

Send your stock data to Verity

Your own systems can send Verity your items, your sales and receipts, and your whole catalogue, over HTTPS with JSON. A request either writes everything it carries or nothing, and says exactly what was wrong when it writes nothing.

Version 1. The same description as a file: openapi.json (OpenAPI 3.1), for generating a client or importing into Postman.

Before you start

  • Make a key. In Verity, open Account settings → API keys. The key is shown once; keep it where your integration keeps secrets.
  • Call from a server. A key in a web page is a key anybody can read, so requests from a browser are refused.
  • Set your currency in Verity if you will send amounts with a currency field. Amounts are stored as sent, never converted.
  • Check the key first:
curl -sS https://useverityapp.com/api/v1/ping \
  -H "Authorization: Bearer $VERITY_KEY"
{
  "api_version": "v1",
  "key": {"display": "vk_live_…Xy3Z"},
  "workspace": {"currency": "EUR", "timezone": "Europe/Budapest"},
  "limits": {"requests_per_minute": 60, "max_body_bytes": 10485760,
             "rows_per_request": 5000, "rows_per_day": 1000000}
}

Limits

Rows in one request5,000 — send more as several requests
Body size10 MB, uncompressed
Requests60 a minute per key
Rows a day1,000,000 per workspace in any 24 hours; refused requests do not count

Past a limit the answer is 429 with a Retry-After header in seconds.

Items

POST /api/v1/items creates or updates items, by SKU. A field you send is set; a field you leave out is kept as it is. An item Verity does not have yet needs name and quantity_on_hand.

curl -sS -X POST https://useverityapp.com/api/v1/items \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"observed_at": "2026-09-15T08:00:00+02:00",
       "items": [{"sku": "JKT-1001-M", "name": "Summit jacket, M", "quantity_on_hand": 14,
                  "unit_cost": 61.5, "unit_price": 129.0, "category": "Jackets",
                  "supplier": "Hohe Tauern Apparel", "reorder_point": 6}]}'
{"dry_run": false, "rows": 1, "created": 1, "updated": 0, "stale": 0,
 "placeholder_suppliers": 0}

The fields: sku (up to 64 characters), name (255), variant (64, shown with the name: "Olive OS", "Black L"), quantity_on_hand (a whole number, may be negative), unit_cost and unit_price (numbers from 0), reorder_point (a whole number from 0), reorder_qty (how many to order when it is time to: a whole number from 1, used as written), lead_time_days (this item's own, 0 to 365; it wins over its supplier's), category (64), supplier (128), barcode (64), location (64), is_active (true or false), and — for what that supplier calls and charges for this item — supplier_sku (64) and supplier_price (a number from 0). Those two describe the item AND a supplier, so a row carrying either must also carry supplier. Text is refused if it has spaces around it, rather than trimmed; 5.0 is not a quantity and "5" is not a number.

Categories and suppliers are matched by name, however they are spelled: "Apparel" and "APPAREL" are one category. A supplier written as text that means nobody — "N/A", "TBD", "unknown" and the like — is not set: the item keeps the supplier it had, and the answer counts the row in placeholder_suppliers. To remove a supplier, clear it.

Send only what changed:

curl -sS -X POST https://useverityapp.com/api/v1/items \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"observed_at": "2026-09-15T09:00:00+02:00",
       "items": [{"sku": "JKT-1001-M", "quantity_on_hand": 11}]}'

Removing a value

null is refused. Many serializers send null for every property that was not set, and reading that as "remove" would wipe values nobody meant to touch. To remove one, name it in clear. The fields that can be cleared: unit_cost, unit_price, reorder_point, reorder_qty, lead_time_days, variant, category, supplier, barcode, location, supplier_sku, supplier_price.

curl -sS -X POST https://useverityapp.com/api/v1/items \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"observed_at": "2026-09-15T09:30:00+02:00",
       "items": [{"sku": "JKT-1001-M", "clear": ["reorder_point"]}]}'

observed_at, and stale rows

observed_at is when your system saw the items as you are sending them, with its UTC offset. If Verity already holds an item as observed later, that row is left as it is and counted as stale — so a retry, or a queue delivering yesterday's stock level, cannot overwrite today's. Sending the same observed_at again is applied, so resending a request is harmless. It may not be more than five minutes in the future. What counts is the observation time, not the order requests arrive in.

Sales, receipts, returns

POST /api/v1/movements records movements. Each carries your own id; sending an id again replaces that movement, so a retry never counts a sale twice. A return is a movement of its own and needs an id of its own: sent with its sale's id, it replaces the sale, and the answer lists that row under changed. type is one of sale, receipt, return, adjustment. quantity is a whole number from 1 — except an adjustment's, whose sign is its direction: positive into stock, negative out of it; never 0. A return is a customer's return: it counts against sales in the window it happens in, never as stock bought. Every sku must already be an item. moved_at is a time with its offset, and no more than 24 hours ahead — a later one is refused. A movement does not change quantity_on_hand — send items for that.

A sale or a return may name its channel (64), where it came through: "Amazon", "POS". Case and spacing make no difference, so "amazon" is the same channel, as it is in a movements file; "AMZ" is another until a movements import joins it to Amazon, after which a push naming "AMZ" is shown as Amazon too. Leave it out for no channel; a word that means none, such as "N/A" or "-", is no channel as well. A receipt or an adjustment has none, and one sent with a channel is refused (not-for-type).

curl -sS -X POST https://useverityapp.com/api/v1/movements \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"movements": [{"id": "order-5521-line-1", "sku": "JKT-1001-M", "type": "sale",
                     "quantity": 1, "unit_price": 129.0, "channel": "Webshop",
                     "moved_at": "2026-09-15T10:12:00+02:00"}]}'
{"dry_run": false, "rows": 1, "created": 1, "updated": 0, "changed": []}

How a movement is valued. A movement's unit_price is what it recorded: what a sale sold for, what a receipt cost. Verity's Stock movement by month report values everything at cost: a receipt at its unit_price, or, sent without one, at the item's unit_cost; a sale, a return and an adjustment at the item's unit_cost. That is the item's cost as it is now, not as it was on the day, so setting or changing an item's unit_cost values its earlier months again: a month shown without a value can gain one, and a value can change. What sales sold for is in the Sales value by month report, from their own unit_price.

Suppliers

POST /api/v1/suppliers creates or updates suppliers. A row carries your own id, the supplier's name, or both: it is matched by your id first, then by the name however it is spelled, so "Acme Ltd" and "ACME ltd" are one supplier. A row that matches nothing creates one, and then needs a name.

A push never renames a supplier. Which spelling it goes by is the merchant's decision, made in Verity — send everything else and that stays as they chose it.

curl -sS -X POST https://useverityapp.com/api/v1/suppliers \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"suppliers": [{"id": "SUP-77", "name": "Hohe Tauern Apparel", "country": "AT",
                     "currency": "EUR", "lead_time_days": 21, "min_order_qty": 10,
                     "min_order_value": 750}]}'
{"dry_run": false, "rows": 1, "created": 1, "updated": 0}

The fields: id (128) and name (128), address_line1 and address_line2 (128), city (64), postal_code (16), region (64), country (two letters), currency (three letters), vat_number (32), lead_time_days (0–365), min_order_qty and pack_size (whole numbers from 1), min_order_value (a number from 0), notes (4,000) and is_active. Everything but the name may be named in clear.

Reading them back

Reading needs a permission; pushing does not — except starting a draft order, whose answer is purchasing data (below). A key is made with what it may read, in Settings, and an existing key never gains one — make a new key instead. GET /api/v1/suppliers answers with the companies and their terms for a key that may read suppliers and orders, and 403 forbidden for one that may not. The same permission reads purchase orders.

curl -sS https://useverityapp.com/api/v1/suppliers \
  -H "Authorization: Bearer $VERITY_READ_KEY"

The people at a supplier are a second permission. They are personal data — a name, an email address, a phone number — so a key that may read prices has not been given them. With ?contacts=true and a key that may read contacts, each supplier carries its contacts; every such read is recorded in the account's security log, with counts and never a name.

curl -sS "https://useverityapp.com/api/v1/suppliers?contacts=true" \
  -H "Authorization: Bearer $VERITY_KEY"

Purchase orders

POST /api/v1/purchase-orders starts a draft for one supplier — and only a draft. An order takes its number, and goes to the supplier, when a person sends it in Verity; a key never can, so a bug in a script cannot become a real order at a real company.

Name the supplier by its Verity id, as GET /api/v1/suppliers answers with it, and each line by its sku. A line may leave quantity out — "not decided yet" — and a draft sent with no lines at all is filled in from what is short, the same suggestion Verity opens a new order with. Starting a draft needs the permission to read suppliers and orders, though it writes: the answer carries the supplier's prices, your costs and, when Verity fills it in, quantities from your sales. A key without it is answered 403 forbidden.

curl -sS -X POST https://useverityapp.com/api/v1/purchase-orders \
  -H "Authorization: Bearer $VERITY_READ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"supplier": '"$SUPPLIER_ID"', "notes": "Autumn restock",
       "lines": [{"sku": "JKT-1001-M", "quantity": 10}]}'
{"id": 1, "number": null, "status": "draft", "supplier": 1,
 "supplier_name": "Hohe Tauern Apparel", "currency": "EUR",
 "created_at": "2026-09-23T08:00:00", "sent_at": null, "expected_at": null,
 "closed_at": null,
 "minimum_order": {"state": "below", "minimum": "750.00", "currency": "EUR",
                   "value": "615.00", "short_by": "135.00",
                   "priced_lines": 1, "unpriced_lines": 0},
 "line_count": 1, "units": 10,
 "lines": [{"id": 1, "item": 1, "sku": "JKT-1001-M", "quantity": 10,
            "received": 0, "unit_price": "61.500000"}]}

A line gives its sku or, if you have it, the item's Verity item id — one of the two. Every SKU must already be an item; a draft naming one that is not is refused whole, with each such line listed as unknown-sku — push the item through POST /api/v1/items first. A SKU is matched exactly, as everywhere in the API.

Each line opens with the supplier's own price for the item, where you have sent one as supplier_price; otherwise with the item's unit_cost, but only when the supplier's currency is your workspace's — a cost is never put on an order in another currency, and such a line has no price until one is set.

minimum_order says where an order stands against its supplier's min_order_value, and is null when the supplier has none. It is a warning, never a refusal: a supplier may ship below its minimum for a surcharge, and an order below it can still be sent. The order's value is each line's quantity times its price, rounded once per line to the currency's places, and summed — in the supplier's currency, never converted. Its state is one of met, below, may_be_below, other_currency: met when the priced lines already reach the minimum; below when every line has a quantity and a price and the order is short_by the difference; may_be_below when it is short on the lines that have both, and unpriced_lines could still make it up; other_currency when the order is in another currency than the minimum, which is then not compared.

Reading them

GET /api/v1/purchase-orders lists orders, and takes status, supplier and limit; GET /api/v1/purchase-orders/{id} is one order with its lines. Both need the permission to read suppliers and orders. An order is always in one of these states: draft, sent, partially_received, received, closed_short, cancelled.

curl -sS https://useverityapp.com/api/v1/purchase-orders/$ORDER_ID \
  -H "Authorization: Bearer $VERITY_READ_KEY"

Recording what arrived

POST /api/v1/purchase-orders/{id}/receipts records a delivery, line by line: each names the order line's id — the lines[].id in the order's answer — and how many came. A delivery is recorded only against an order that has been sent; before that it is refused.

curl -sS -X POST https://useverityapp.com/api/v1/purchase-orders/$ORDER_ID/receipts \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"lines": [{"line": '"$LINE_ID"', "quantity": 10}]}'

Send the order in Verity first. A person does that, and the order takes its number then — the API cannot. Once it is sent:

curl -sS -X POST https://useverityapp.com/api/v1/purchase-orders/$ORDER_ID/receipts \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id": "DEL-2026-0917", "add_to_stock": true,
       "lines": [{"line": '"$LINE_ID"', "quantity": 10}]}'
{"receipt": 1, "repeated": false, "status": "received", "movements": 1}

add_to_stock is false unless you say so. If your own system counts stock, recording the delivery here as well would count it twice, and Verity cannot know which of the two you use. With it, the delivery is added to quantity_on_hand and recorded as a receipt, at the order line's price when the order is in your workspace's currency. An order in another currency is recorded without one, and valued at the item's cost: Verity converts nothing. Your id makes a retry safe: the same delivery sent twice is recorded once, and the second answer says "repeated": true.

Trying a request first

Add ?dry_run=true to any write. Every check a real request makes is made, and the answer says what would happen; nothing is written.

curl -sS -X POST "https://useverityapp.com/api/v1/items?dry_run=true" \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"observed_at": "2026-09-15T10:30:00+02:00",
       "items": [{"sku": "JKT-1001-M", "quantity_on_hand": 10}]}'

Sending the whole catalogue

A snapshot tells Verity "this is everything". Open one, send the catalogue in pages, and complete it: every active item no page listed is marked inactive — never deleted — unless a regular push observed it after the snapshot's observed_at. One snapshot may be open per workspace, for 24 hours; only the key that opened it may use it.

curl -sS -X POST https://useverityapp.com/api/v1/snapshots \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"observed_at": "2026-09-15T11:00:00+02:00"}'

Each page is an items request without its own observed_at. A listed item becomes active again unless its row sends "is_active": false.

curl -sS -X POST https://useverityapp.com/api/v1/snapshots/$SNAPSHOT_ID/items \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"sku": "JKT-1001-M"}]}'

Completing needs no body. If more than half of the active items would be deactivated it is refused with the counts (large-deactivation), and nothing changes, unless you send {"allow_large_deactivation": true}. Try it with dry_run first:

curl -sS -X POST https://useverityapp.com/api/v1/snapshots/$SNAPSHOT_ID/complete \
  -H "Authorization: Bearer $VERITY_KEY"
{"dry_run": false, "status": "completed", "large_deactivation": false,
 "result": {"active_before": 1, "deactivated": 0, "deactivated_with_stock": 0}}
curl -sS https://useverityapp.com/api/v1/snapshots/$SNAPSHOT_ID \
  -H "Authorization: Bearer $VERITY_KEY"

DELETE /api/v1/snapshots/{id} abandons an open snapshot: nothing is deactivated, and pages already sent stay written.

When something is wrong

Every error has the same shape. kind says what happened; errors lists the problems with a request, by row (counted from 0), field and code — never the value you sent. A request with any problem writes nothing.

curl -sS -X POST https://useverityapp.com/api/v1/items \
  -H "Authorization: Bearer $VERITY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"observed_at": "2026-09-15T12:00:00+02:00",
       "items": [{"sku": "JKT-1002-L", "unit_cost": null}]}'
{"detail": {"detail": "Nothing was written: the request has problems, listed below by row and field. Rows are counted from 0.",
            "kind": "invalid-request",
            "errors": [{"row": 0, "field": "unit_cost", "code": "null-not-allowed"}],
            "errors_total": 1}}

Every answer carries an X-Request-ID header; quote it if you need to ask us about a request.

Kinds

kindStatusMeaning
invalid-json400The body is not JSON: not UTF-8, a repeated key, NaN, or nested too deep.
invalid-length400The Content-Length header is not a number.
bad-request400The request could not be understood.
key-not-accepted401No key, or a key that is not accepted. The same answer whatever the reason.
browser-origin403Sent from a web browser. Call the API from a server.
forbidden403Not allowed.
not-found404No such path.
no-such-snapshot404No snapshot with that id for this key.
method-not-allowed405That path does not take that method; see the Allow header.
conflict409The request conflicts with the current state.
workspace-busy409Something else is writing to the workspace. Retry after Retry-After.
workspace-updating503The workspace's tables are being updated. Nothing was written; retry after Retry-After.
workspace-not-provisioned409The workspace holds no data tables yet.
snapshot-already-open409The workspace already has an open snapshot.
snapshot-not-open409The snapshot was completed, abandoned or has expired.
large-deactivation409Completing would deactivate more than half of the active items.
body-too-large413The body is larger than one request may be.
compressed-body415The body is compressed. Send it uncompressed.
unsupported-media-type415The body is not sent as application/json.
invalid-request422The request has problems, listed in errors. Nothing was written.
purchasing-refused422A rule of purchasing refuses this — an order that cannot be drafted, or a delivery that does not fit the order. The reason is in detail, and nothing was written.
account-deletion-scheduled423The account is scheduled for deletion.
rate-limited429Too many requests this minute. Retry after Retry-After.
daily-row-limit429The workspace has sent as many rows as 24 hours allow.
unexpected500Something unexpected. Nothing is reported as written; quote X-Request-ID.
database-error500The database refused a query. Quote X-Request-ID.
database-unreachable503The database could not be reached. Worth retrying shortly.
unavailable503Temporarily unavailable.
timeout504The request took too long and was stopped.

Problem codes

codeWhat was wrong
wrong-typeThe wrong kind of value, such as "5" for a number.
requiredA required field is missing.
unknown-fieldA field the API does not have.
null-not-allowednull. Leave the field out to keep it, or name it in clear.
emptyEmpty text, or an empty list of rows.
too-many-rowsMore rows than one request may carry.
too-longLonger than the field allows.
surrounding-whitespaceText with spaces around it.
invalid-characterA control character, which cannot be stored.
out-of-rangeOutside the allowed range.
too-many-decimalsMore than 6 decimal places. Trailing zeros do not count: 1.5000000 is 1.5.
invalid-valueNot one of the allowed values.
invalid-timestampNot an RFC 3339 time.
timestamp-needs-offsetA time without its UTC offset.
too-earlyBefore the year 2000.
too-far-in-futureToo far in the future.
not-clearableA field that cannot be cleared.
set-and-clearedA field both set and named in clear.
listed-twiceA field named twice in clear.
duplicate-skuA SKU on more than one row of the request.
required-to-createNeeded to create an item Verity does not have yet.
duplicate-idAn id on more than one row of the request.
unknown-skuA SKU the workspace does not have.
not-for-typeA field this movement's type does not carry, such as a channel on a receipt.
both-givenTwo fields that name the same thing, sent together — a line with both a sku and an item.
currency-not-setA currency was sent, but the workspace has none set.
currency-mismatchA currency different from the workspace's.
invalidNot accepted.

Clients

Anything that can send HTTPS requests from a server works. Measured to be accepted: curl; Python with requests, httpx or urllib; Node.js with fetch or http (and so axios). Deno and Bun have not been checked yet. A request carrying a browser's Origin, Sec-Fetch-Site or Sec-Fetch-Dest header is refused.