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.
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
currencyfield. 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 request | 5,000 — send more as several requests |
|---|---|
| Body size | 10 MB, uncompressed |
| Requests | 60 a minute per key |
| Rows a day | 1,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
| kind | Status | Meaning |
|---|---|---|
invalid-json | 400 | The body is not JSON: not UTF-8, a repeated key, NaN, or nested too deep. |
invalid-length | 400 | The Content-Length header is not a number. |
bad-request | 400 | The request could not be understood. |
key-not-accepted | 401 | No key, or a key that is not accepted. The same answer whatever the reason. |
browser-origin | 403 | Sent from a web browser. Call the API from a server. |
forbidden | 403 | Not allowed. |
not-found | 404 | No such path. |
no-such-snapshot | 404 | No snapshot with that id for this key. |
method-not-allowed | 405 | That path does not take that method; see the Allow header. |
conflict | 409 | The request conflicts with the current state. |
workspace-busy | 409 | Something else is writing to the workspace. Retry after Retry-After. |
workspace-updating | 503 | The workspace's tables are being updated. Nothing was written; retry after Retry-After. |
workspace-not-provisioned | 409 | The workspace holds no data tables yet. |
snapshot-already-open | 409 | The workspace already has an open snapshot. |
snapshot-not-open | 409 | The snapshot was completed, abandoned or has expired. |
large-deactivation | 409 | Completing would deactivate more than half of the active items. |
body-too-large | 413 | The body is larger than one request may be. |
compressed-body | 415 | The body is compressed. Send it uncompressed. |
unsupported-media-type | 415 | The body is not sent as application/json. |
invalid-request | 422 | The request has problems, listed in errors. Nothing was written. |
purchasing-refused | 422 | A 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-scheduled | 423 | The account is scheduled for deletion. |
rate-limited | 429 | Too many requests this minute. Retry after Retry-After. |
daily-row-limit | 429 | The workspace has sent as many rows as 24 hours allow. |
unexpected | 500 | Something unexpected. Nothing is reported as written; quote X-Request-ID. |
database-error | 500 | The database refused a query. Quote X-Request-ID. |
database-unreachable | 503 | The database could not be reached. Worth retrying shortly. |
unavailable | 503 | Temporarily unavailable. |
timeout | 504 | The request took too long and was stopped. |
Problem codes
| code | What was wrong |
|---|---|
wrong-type | The wrong kind of value, such as "5" for a number. |
required | A required field is missing. |
unknown-field | A field the API does not have. |
null-not-allowed | null. Leave the field out to keep it, or name it in clear. |
empty | Empty text, or an empty list of rows. |
too-many-rows | More rows than one request may carry. |
too-long | Longer than the field allows. |
surrounding-whitespace | Text with spaces around it. |
invalid-character | A control character, which cannot be stored. |
out-of-range | Outside the allowed range. |
too-many-decimals | More than 6 decimal places. Trailing zeros do not count: 1.5000000 is 1.5. |
invalid-value | Not one of the allowed values. |
invalid-timestamp | Not an RFC 3339 time. |
timestamp-needs-offset | A time without its UTC offset. |
too-early | Before the year 2000. |
too-far-in-future | Too far in the future. |
not-clearable | A field that cannot be cleared. |
set-and-cleared | A field both set and named in clear. |
listed-twice | A field named twice in clear. |
duplicate-sku | A SKU on more than one row of the request. |
required-to-create | Needed to create an item Verity does not have yet. |
duplicate-id | An id on more than one row of the request. |
unknown-sku | A SKU the workspace does not have. |
not-for-type | A field this movement's type does not carry, such as a channel on a receipt. |
both-given | Two fields that name the same thing, sent together — a line with both a sku and an item. |
currency-not-set | A currency was sent, but the workspace has none set. |
currency-mismatch | A currency different from the workspace's. |
invalid | Not 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.