AWRA OpsHub Search

Developer API · v1

API Reference

An API over your organization's inventory, sales, and procurement records that reads them and creates and updates their master data — authenticated with an API key you issue yourself, scoped to exactly the areas you choose, and safe to hand to a third-party developer.

Quickstart

  1. In AWRA OpsHub, open Settings → API & Webhooks and create a key. Choose the service Developer API and tick only the scopes the integration needs.
  2. Copy the token immediately. It is shown once — only a SHA-256 hash is stored, so it cannot be recovered later. If you lose it, rotate the key.
  3. Confirm it works by calling /me:
curl https://awraops.com/api/v1/me \
  -H "Authorization: Bearer awra_your_key_here" \
  -H "Accept: application/json"

A 200 response naming your organization means the key is live. A 401 means the token is wrong, revoked, or expired.

Authentication

Every request carries the key as a bearer token:

Authorization: Bearer awra_your_key_here

If your proxy or gateway already reserves the Authorization header, send the key as X-Api-Key instead. The two are equivalent; use one or the other.

There is no login step, no token refresh, and no expiry unless you set one when creating the key. Keys can be rotated or revoked at any time from the same settings screen, and revocation takes effect on the next request.

Treat the key like a password

Anyone holding it can read everything its scopes allow. Keep it in a secrets manager, never in source control or browser JavaScript, and give each integration its own key so you can revoke one without breaking the others.

Organizations

AWRA OpsHub hosts many organizations, and every record belongs to exactly one of them. The API resolves your organization from the key itself. When you create a key, it is permanently bound to the organization you created it in.

This is why you will not find an organization ID anywhere in these docs — not in a URL, not in a header, not in a request body. There is nothing to pass and nothing to get wrong. A key cannot be pointed at another organization, because the binding is not something the client supplies.

Records outside your organization return 404 not_found — the same response as an ID that does not exist at all. The API never reveals whether an ID is in use somewhere else.

Scopes

Scopes are chosen when the key is created and cannot be widened afterwards — to change them, issue a new key. Grant the narrowest set that does the job.

ScopeAreaGrants access to
developer:read Read everything Umbrella scope. Satisfies every read scope below (never a write). Convenient for a trusted internal integration; prefer narrow scopes for third parties.
inventory:read Inventory Items, categories, warehouses, and the stock adjustments and transfers raised through the API.
sales:read Sales Customers and customer invoices, including invoice line items.
procurement:read Procurement Vendors, purchase orders (with line items) and requisitions.
expenses:read Expenses Expenses and their approval status.
developer:write Read and write everything Satisfies every read and write scope on this page except approvals:write, which must be granted by name. A key holding only developer:read can never write.
inventory:write Inventory writes Create, update and delete items (master data). Also reads inventory.
sales:write Sales writes Create, update and delete customers. Also reads sales. (Invoices moved to invoices:write on 2026-10-30; keys that held sales:write then were granted it.)
procurement:write Procurement writes Create, update and delete vendors (payout details are changed only in the product, under dual control). Also reads procurement.
stock:write Stock writes Raise stock adjustments and stock transfers. Both wait for approval before anything moves.
purchasing:write Purchasing writes Raise purchase requisitions, raise purchase orders from approved quotations, and cancel an order still waiting for its goods.
expenses:write Expense writes Record expenses. Also reads expenses.
invoices:write Invoice writes Draft and issue customer invoices and record customer payments against them.
sales_orders:write Sales order writes Create draft sales orders and confirm them (which reserves stock).
bills:write Supplier bill writes Capture draft supplier bills and post them to the ledger (never a bill the key's owner entered).
payments:write Vendor payment writes Prepare a vendor payment for supplier bills and submit it for approval. Nothing is paid until a second person approves it and it is executed in AWRA.
claims:write Expense claim writes File and submit staff expense claims for the employee record linked to the key's owner.
approvals:write Approval decisions Approve or reject stock adjustments, stock transfers, requisitions, expenses, expense claims and vendor payments; award quotations; sign purchase orders off for payment. Always as the person who created the key, never on something that person raised. No umbrella grants this: since 2026-10-30 it must be granted by name (keys that held developer:write then were granted it).

Calling an endpoint your key is not scoped for returns 403 insufficient_scope. Retrying will not help — the key needs to be reissued. /me is reachable by any valid key regardless of scope, so a narrowly-scoped integration can still verify itself.

Conventions

Base URL

https://awraops.com/api/v1

Machine-readable specification

This reference is also published as an OpenAPI 3.1 document at https://awraops.com/openapi.json, generated from the same source. Point a client generator, Postman, or an agent at it rather than transcribing endpoints by hand. Response examples are attached to each operation; typed response schemas are deliberately not asserted, because an inferred schema a generator trusts is worse than an example a developer reads.

Response shape

Single records return under data. Lists add meta and links. Errors return an error object instead of data — check for the key, not the shape.

{
  "data": [ … ],
  "meta":  { "page": 1, "per_page": 25, "total": 37, "total_pages": 2 },
  "links": { "next": "…?page=2", "prev": null }
}

Paging: numbered pages or a cursor

Numbered pages are the default and give you random access plus a total. For reading a whole resource — an export, a nightly sync — ask for a cursor instead: it costs the same at page 1,000 as at page 1, and it cannot skip or repeat a record if rows change while you page. Follow links.next until it is null.

# Numbered pages (default) — random access, and a total to show "page 3 of 12".
GET /items?page=3&per_page=100

# Cursor walk — for reading a whole resource. Start it, then follow links.next.
GET /items?paginate=cursor&per_page=100

{
  "data": [ … ],
  "meta":  { "pagination": "cursor", "per_page": 100, "has_more": true },
  "links": { "next": "…?cursor=eyJpZCI6MTAwfQ", "prev": null }
}

# Stop when links.next is null. Do not build cursors yourself.

Parameters on every list endpoint

ParameterTypeDescription
page integer Page number. Defaults to 1.
per_page integer Results per page. Defaults to 25, capped at 100. Values below 1 fall back to the default.
updated_since ISO 8601 Only records changed at or after this timestamp — use it for incremental sync instead of re-reading everything. An unparseable value returns 422 rather than being ignored.
paginate string Set to "cursor" to start a cursor walk instead of numbered pages. Use this when reading a whole resource: page 1,000 costs what page 1 costs, and no row is skipped or repeated if records change while you page. The response carries no total, and you follow links.next rather than incrementing a page number.
cursor string The opaque cursor from a previous response's links.next or links.prev. Sending one implies cursor mode, so following links.next needs nothing else. Do not construct these yourself — they encode the sort key of the last row returned.

Data types

  • Money is a string, always with two decimals — "139200.00". Parsing money as a JSON number invites floating-point drift; treat these as decimals in your language of choice.
  • Timestamps are ISO 8601 with the organization's offset — "2026-07-14T11:05:00+03:00".
  • Item IDs are UUID strings; customer, invoice, vendor, and purchase-order IDs are integers. Store them as given rather than casting.
  • Trashed records are omitted. Items, customers, invoices, vendors, and purchase orders the organization has moved to trash do not appear in any response. Categories and warehouses are deleted outright and simply stop being listed.

Syncing efficiently

Store the timestamp of your last successful run and pass it as updated_since on the next one. You will get only what changed, which keeps you well inside the rate limit as the dataset grows. Note that this reflects changes, not deletions — reconcile with a full read periodically if you need to detect removed records.

Errors

Every error is an RFC 7807 problem document, sent as application/problem+json: type, title, status and detail, plus code to branch on. A validation failure adds errors, each field with its messages. The older error.code and error.message members are still there, so a client written against them keeps working.

HTTP/1.1 403 Forbidden
Content-Type: application/problem+json

{
  "type": "https://awraops.com/api-docs#problem-insufficient_scope",
  "title": "Forbidden",
  "status": 403,
  "detail": "This API key is missing the scope required for this endpoint. Required: sales:read.",
  "code": "insufficient_scope",
  "error": { "code": "insufficient_scope", "message": "This API key is missing the scope required for this endpoint. Required: sales:read." }
}
StatusCodeMeaning
200 — Success. A create answers 201; an approval that still has steps left in your approval chain answers 202.
400 idempotency_key_required A stock, money or approval write was sent without an Idempotency-Key header. Add one (a UUID is fine) and send again.
401 unauthorized No bearer token, or the key is unknown, revoked, or past its expiry date. Not retryable — check the credential.
402 plan_restricted Your plan does not include the action. Retrying will not help.
403 insufficient_scope The key is valid but lacks the scope this endpoint requires. Mint a key with the right scope; retrying will not help.
403 ip_not_allowed The workspace has an IP allowlist and this call came from an address not on it. Add the address under Settings → Security; retrying from the same address will not help.
403 key_has_no_owner A write that acts as a person was sent with a key whose creator is no longer an active member. Have an active member issue a new key.
403 permission_denied The person the key acts as does not hold the permission the action needs. The response names it in `permission`.
403 segregation_of_duties The request was raised by the person the key acts as. Somebody else has to decide it.
403 action_refused One of your organization's rules refused the action: a value threshold, an approval chain step that belongs to someone else, or a similar control. The detail says which.
404 not_found No such record in your organization. Also returned for records that exist but belong to another organization — the API never confirms that an ID exists elsewhere.
405 method_not_allowed The endpoint does not accept that HTTP method.
409 invalid_state The record is not in a state for this action, for example an adjustment that is no longer pending or an invoice that is not a draft.
409 conflict The write conflicts with what is there: the quotation is already ordered, the source does not hold the stock, or a duplicate value.
409 idempotency_conflict The Idempotency-Key was already used with a different request body. Use a new key for a new write.
409 idempotency_in_progress A request with the same Idempotency-Key is still running. Retry after Retry-After seconds to receive its result.
422 validation_failed The body failed validation. `errors` maps each field to its messages.
422 invalid_parameter A query parameter was malformed. The message names the offending parameter.
422 unprocessable The product refused the input as a whole, for example a receipt above what was ordered or a payment above the balance due. The detail says why.
429 rate_limited More than 120 requests a minute on this key. Wait for Retry-After seconds.
429 daily_quota_exceeded The workspace has used its plan’s API calls for today, across all of its keys. Retry-After counts down to midnight UTC.
500 server_error Something failed on our side and was logged. Retry a write with the same Idempotency-Key: if the first attempt did complete, you get its result rather than a second write.

Retry 429 and 5xx with exponential backoff. Do not retry 401, 403, 404, or 422 — those need a change on your side. The one 429 not to retry is daily_quota_exceeded: it lasts until midnight UTC, so stop and resume tomorrow rather than backing off.

Rate limits

120 requests per minute per API key. The budget is counted per key, so one integration cannot starve another — and issuing separate keys per integration gives each its own allowance.

Exceeding it returns 429. Standard X-RateLimit-Limit and X-RateLimit-Remaining headers accompany responses, and Retry-After tells you how long to wait.

A daily total per workspace. On top of the per-minute limit, each workspace has a number of API calls a day set by its plan, shared by all of its keys — so separate keys do not add to it. It resets at midnight UTC. Going over it returns 429 with the error code daily_quota_exceeded; the plan comparison lists each plan's figure. Only calls made with a valid key count. The plan also sets how many active keys a workspace can hold.

Endpoint reference

Reads take a read scope; the three write groups create and update items, customers and vendors and need a write scope by name. All paths are relative to https://awraops.com/api/v1.

Identity

any valid key

Confirms a key works and shows which organization it is bound to. Call this first when wiring up a new integration.

GET /me

Introspect the calling API key: the organization it belongs to, its scopes, and its expiry.

Example response

{
  "data": {
    "organization": { "id": "1", "name": "Example Organization" },
    "key": {
      "name": "Warehouse sync",
      "prefix": "awra_1A6Kw",
      "scopes": ["inventory:read"],
      "created_at": "2026-08-03T18:35:26+03:00",
      "expires_at": null
    }
  }
}

Inventory

inventory:read

Stock positions and the reference data around them. Stock figures are the current on-hand quantity across the organization.

GET /items

List items with current stock levels.

Query parameters

NameTypeDescription
search string Matches name, SKU (barcode), or description.
category string Exact category name.
item_type string stock or service. A service carries no stock: its tracks_stock is false and its stock is always 0.
status string active or inactive. Omitted, every item is returned. An inactive item is discontinued: kept for history, hidden from new work.
low_stock boolean When true, only active stocked items whose stock has fallen to or below their reorder point. Services and inactive items are never included.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": [
    {
      "id": "11111111-2222-3333-4444-555555555555",
      "sku": "SKU-EXAMPLE-001",
      "name": "Example Widget",
      "description": "Illustrative item description",
      "category": "Office Supplies",
      "item_type": "stock",
      "tracks_stock": true,
      "stock": 681,
      "reorder_point": 100,
      "safety_stock": 40,
      "lead_time_days": 7,
      "tracking_mode": "none",
      "is_active": true,
      "deactivated_at": null,
      "buying_price": "540.00",
      "selling_price": "700.00",
      "last_movement_at": "2026-08-01T09:12:44+03:00",
      "created_at": "2026-05-02T10:00:00+03:00",
      "updated_at": "2026-08-01T09:12:44+03:00"
    }
  ],
  "meta": { "page": 1, "per_page": 25, "total": 37, "total_pages": 2 },
  "links": { "next": "https://app.example.com/api/v1/items?page=2", "prev": null }
}
GET /items/{id}

Fetch a single item by its ID.

Example response

{ "data": { "id": "11111111-2222-…", "sku": "SKU-EXAMPLE-001", "name": "Example Widget", "stock": 681, "…": "…" } }
GET /categories

List item categories. Returns the full set, unpaginated.

Example response

{ "data": [ { "id": 6, "name": "Electronics" }, { "id": 4, "name": "Food" } ] }
GET /warehouses

List warehouses. Returns the full set, unpaginated.

Example response

{ "data": [ { "id": 1, "name": "Main Warehouse" }, { "id": 4, "name": "Field Store" } ] }

Sales

sales:read

Customers and the invoices raised against them — the surface most accounting and BI integrations need.

GET /customers

List customers with balances and credit limits.

Query parameters

NameTypeDescription
search string Matches company name, first or last name, email, or phone.
status string Exact status value.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": [
    {
      "id": 2,
      "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
      "company_name": "Example Trading Ltd",
      "first_name": "Jane",
      "last_name": "Doe",
      "email": "[email protected]",
      "phone": "+254700000000",
      "country": "Kenya",
      "address": "Nairobi",
      "currency_code": "KES",
      "credit_limit": "500000.00",
      "balance": "128400.00",
      "status": "active",
      "created_at": "2026-04-11T08:20:00+03:00",
      "updated_at": "2026-07-30T16:02:11+03:00"
    }
  ],
  "meta": { "page": 1, "per_page": 25, "total": 2, "total_pages": 1 },
  "links": { "next": null, "prev": null }
}
GET /customers/{id}

Fetch a single customer by ID.

Example response

{ "data": { "id": 2, "company_name": "Example Trading Ltd", "balance": "128400.00", "…": "…" } }
GET /invoices

List customer invoices with totals and outstanding balances.

Query parameters

NameTypeDescription
status string Exact invoice status, e.g. draft, sent, paid.
customer_id integer Only invoices for this customer.
issued_from date Invoice date on or after this date (YYYY-MM-DD).
issued_to date Invoice date on or before this date (YYYY-MM-DD).
unpaid boolean When true, only invoices with a balance still due.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": [
    {
      "id": 10,
      "invoice_number": "INV-2026-000010",
      "status": "draft",
      "customer": { "id": 1, "name": "Example Trading Ltd" },
      "invoice_date": "2026-07-14T00:00:00+03:00",
      "due_date": "2026-08-13T00:00:00+03:00",
      "currency_code": "KES",
      "subtotal": "120000.00",
      "discount_total": "0.00",
      "tax_amount": "19200.00",
      "total": "139200.00",
      "amount_paid": "0.00",
      "balance_due": "139200.00",
      "created_at": "2026-07-14T11:05:00+03:00",
      "updated_at": "2026-07-14T11:05:00+03:00"
    }
  ],
  "meta": { "page": 1, "per_page": 25, "total": 10, "total_pages": 1 },
  "links": { "next": null, "prev": null }
}
GET /invoices/{id}

Fetch a single invoice, including its line items.

Example response

{
  "data": {
    "id": 10,
    "invoice_number": "INV-2026-000010",
    "status": "draft",
    "total": "139200.00",
    "balance_due": "139200.00",
    "line_items": [
      {
        "item_id": "11111111-2222-3333-4444-555555555555",
        "description": "Example Widget",
        "quantity": 200,
        "unit_price": "600.00",
        "discount": "0.00",
        "tax_rate": 16,
        "line_total": "120000.00"
      }
    ]
  }
}

Procurement

procurement:read

Vendors and purchase orders. Vendor banking and mobile-money details are deliberately excluded from every response — a read key is for reporting and sync, not for harvesting payout destinations.

GET /vendors

List vendors.

Query parameters

NameTypeDescription
search string Matches name, company, email, or phone.
is_active boolean Filter by active flag.
preferred_only boolean When true, only vendors marked preferred.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": [
    {
      "id": 5,
      "name": "Example Supplies",
      "company": "Example Supplies Ltd",
      "email": "[email protected]",
      "phone": "254700000000",
      "country": "Kenya",
      "address": "Nairobi",
      "is_active": true,
      "is_preferred": false,
      "is_blacklisted": false,
      "created_at": "2026-03-02T09:00:00+03:00",
      "updated_at": "2026-07-19T14:41:00+03:00"
    }
  ],
  "meta": { "page": 1, "per_page": 25, "total": 8, "total_pages": 1 },
  "links": { "next": null, "prev": null }
}
GET /vendors/{id}

Fetch a single vendor by ID.

Example response

{ "data": { "id": 5, "name": "Example Supplies", "is_active": true, "…": "…" } }
GET /purchase-orders

List purchase orders with payment and shipping state.

Query parameters

NameTypeDescription
status string Exact order status.
payment_status string Exact payment status, e.g. unpaid, partial, paid.
vendor_id integer Only orders for this vendor.
ordered_from date Order date on or after this date (YYYY-MM-DD).
ordered_to date Order date on or before this date (YYYY-MM-DD).

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": [
    {
      "id": 5,
      "po_number": "PO-2026-000003",
      "status": "paid",
      "payment_status": "paid",
      "shipping_status": "delivered",
      "vendor": { "id": 1, "name": "Example Supplies Ltd" },
      "order_date": "2026-08-02T14:50:40+03:00",
      "delivery_date": "2026-08-09T00:00:00+03:00",
      "total_amount": "84000.00",
      "amount_paid": "84000.00",
      "balance_due": "0.00",
      "tracking_number": "TRK-99120",
      "created_at": "2026-08-02T14:50:40+03:00",
      "updated_at": "2026-08-02T15:10:02+03:00"
    }
  ],
  "meta": { "page": 1, "per_page": 25, "total": 5, "total_pages": 1 },
  "links": { "next": null, "prev": null }
}
GET /purchase-orders/{id}

Fetch a single purchase order, including its line items.

Example response

{
  "data": {
    "id": 5,
    "po_number": "PO-2026-000003",
    "status": "paid",
    "total_amount": "84000.00",
    "line_items": [
      { "item_id": "11111111-2222-…", "quantity": 120, "price": "700.00", "total_cost": "84000.00" }
    ]
  }
}

Inventory writes

inventory:write

Create, update and delete master data: items, customers and vendors. A delete moves the record to the trash, where it can be restored, and is refused with a 409 has_dependents while open documents still use it. A read key, the developer:read umbrella and a "*" key never write; mint a key with a write scope. An item's stock moves through stock adjustments (below), never by editing the item, and a vendor's payout details change only in the product, under dual control — both are refused here with a 422. On these master-data creates an Idempotency-Key is optional: send one and a retry returns the first result instead of a duplicate. Every write is recorded in your audit log against the key.

POST /items

Create an item. Starts at zero stock. Needs inventory:write.

Query parameters

NameTypeDescription
name string Required on create.
category string Required on create.
sku string Barcode / SKU, unique in your organization.
item_type string "stock" (default) or "service". Create only.
buying_price number Unit cost.
selling_price number Unit price; for a stock item the markup is worked back from it.
description string Free text.
reorder_point integer Low-stock threshold.
is_active boolean true (default) or false. A new item may start inactive.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": "2026101500001", "name": "Widget", "category": "Hardware", "selling_price": "150.00", "stock": 0 }
}
PATCH /items/{id}

Update an item. Send only the fields to change; stock and item_type are refused.

Query parameters

NameTypeDescription
name string Required on create.
category string Required on create.
sku string Barcode / SKU, unique in your organization.
item_type string "stock" (default) or "service". Create only.
buying_price number Unit cost.
selling_price number Unit price; for a stock item the markup is worked back from it.
description string Free text.
reorder_point integer Low-stock threshold.
is_active boolean false makes the item inactive (refused with a 422 while it has stock or an open hold); true makes it active again.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": "2026101500001", "name": "Widget", "category": "Hardware", "selling_price": "150.00", "stock": 0 }
}
DELETE /items/{id}

Move an item to the trash, where it can be restored. Refused with a 409 has_dependents while it has stock, quotations or check-ins and check-outs. The key owner needs delete_items.

Example response

{
  "data": { "id": 12, "deleted": true, "restorable": true }
}

Sales writes

sales:write

Create, update and delete customers. Same rules as inventory writes: a write scope by name, Idempotency-Key on POST, audited against the key.

POST /customers

Create a customer. Needs sales:write.

Query parameters

NameTypeDescription
company_name string A company_name or a first_name is required on create.
first_name string For an individual.
last_name string For an individual.
email string Billing email.
phone string Phone number.
currency_code string ISO 4217, three letters.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 12, "company_name": "Acme Ltd", "email": "[email protected]" }
}
PATCH /customers/{id}

Update a customer. Send only the fields to change.

Query parameters

NameTypeDescription
company_name string A company_name or a first_name is required on create.
first_name string For an individual.
last_name string For an individual.
email string Billing email.
phone string Phone number.
currency_code string ISO 4217, three letters.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 12, "company_name": "Acme Ltd", "email": "[email protected]" }
}
DELETE /customers/{id}

Move a customer to the trash, where it can be restored. Refused with a 409 has_dependents while it has invoices or payments. The key owner needs delete_customers.

Example response

{
  "data": { "id": 12, "deleted": true, "restorable": true }
}

Procurement writes

procurement:write

Create, update and delete vendors. Bank and mobile-money payout fields are refused with a 422: where a supplier is paid changes in the product, where a second person can be required and the supplier is told.

POST /vendors

Create a vendor. Needs procurement:write.

Query parameters

NameTypeDescription
name string Required on create.
email string Required on create; unique in your organization.
phone string Required on create.
company string Trading name.
address string Postal or street address.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 7, "name": "Supplies Co", "email": "[email protected]", "phone": "+254700000001" }
}
PATCH /vendors/{id}

Update a vendor. Bank and mobile-money fields are refused; change those in the product, under dual control.

Query parameters

NameTypeDescription
name string Required on create.
email string Required on create; unique in your organization.
phone string Required on create.
company string Trading name.
address string Postal or street address.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 7, "name": "Supplies Co", "email": "[email protected]", "phone": "+254700000001" }
}
DELETE /vendors/{id}

Move a vendor to the trash, where it can be restored. Refused with a 409 has_dependents while it has quotations, purchase orders or portal users. The key owner needs delete_vendors.

Example response

{
  "data": { "id": 12, "deleted": true, "restorable": true }
}

Stock writes

stock:write

Raise stock adjustments and transfers. They go through the same actions as the app: the item must be an active stock item, the location must belong to the warehouse, a warehouse-restricted person stays in their warehouse, and a receipt cannot exceed its order. Everything is raised PENDING and moves no stock until someone approves it. These writes act as the person who created the key, with that person's permissions, so a key whose creator has left is refused. They need an Idempotency-Key, and a read scope or "*" never allows them.

POST /stock-adjustments

Raise a pending check-in (stock in) or check-out (stock out). The key owner needs the checkin or checkout permission.

Query parameters

NameTypeDescription
type string "checkin" or "checkout". Required.
warehouse_id integer Required.
location_id integer Optional; must belong to the warehouse, or the request is refused with a 422.
reason string An active adjustment reason name. Omitted, your default reason for the type is used.
reference string Your reference. Generated when omitted.
notes string Free text.
lines array Required, 1–200 rows of item_id, quantity (> 0) and, on a check-in, an optional unit_cost.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 431, "reference": "ADJ-20261030-0001", "type": "checkout", "status": "pending", "warehouse_id": 3, "lines": [{ "item_id": "2026101500001", "quantity": 2 }] }
}
GET /stock-adjustments/{id}

Read an adjustment and its status: pending, approved, adjusted or rejected. Needs inventory:read, stock:write or approvals:write.

Example response

{
  "data": { "id": 431, "status": "adjusted", "approved_by": 12 }
}
POST /stock-transfers

Raise a pending transfer of one item between warehouses or locations. Refused with a 409 when the source does not hold the quantity. The key owner needs adjust_adjustment.

Query parameters

NameTypeDescription
item_id string Required.
quantity number Required, > 0.
from_warehouse_id integer Required.
from_location_id integer Optional; must belong to the source warehouse.
to_warehouse_id integer Required.
to_location_id integer Optional; must belong to the destination warehouse.
notes string Free text.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 77, "transfer_number": "TR-20261030-001", "item_id": "2026101500001", "quantity": 5, "status": "pending" }
}
GET /stock-transfers/{id}

Read a transfer and its status. Needs inventory:read, stock:write or approvals:write.

Example response

{
  "data": { "id": 77, "status": "in_transit", "approved_at": "2026-10-30T10:02:11+03:00" }
}

Purchasing writes

purchasing:write

A purchase order in AWRA always comes from an approved quotation, and a quotation from a requisition. The API follows the same path. Raise a requisition, have it decided, and raise the order once a quotation is approved. Value-based approval chains, budget blocks and purchase policy run exactly as on screen.

POST /purchase-requisitions

Raise and submit a requisition with its lines. It waits for approval. The key owner needs add_requests and, unless an admin, a department on their profile.

Query parameters

NameTypeDescription
department_id integer Used for an admin owner; anyone else raises for their own department.
description string What the request is for.
warehouse_id integer Where the goods are wanted.
location_id integer Optional; must belong to the warehouse.
lines array Required: item_id, quantity (> 0) and price (expected unit cost).

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 58, "reference": "PR-2026103001", "status": "pending", "lines": [{ "item_id": "2026101500001", "quantity": 40, "price": "650.00" }] }
}
GET /purchase-requisitions/{id}

Read a requisition and its status. Needs procurement:read, purchasing:write or approvals:write.

Example response

{
  "data": { "id": 58, "status": "waiting procurement" }
}
POST /purchase-orders

Raise the purchase order for an approved quotation. A quotation can be ordered once; a second call answers 409. The key owner needs create_purchase_orders.

Query parameters

NameTypeDescription
quotation_id integer Required: an approved quotation in your organization.
warehouse_id integer Delivery warehouse; defaults to the quotation's.
location_id integer Optional; must belong to the warehouse.
department_id integer Defaults to the requisition's department.
project_id integer An open project to charge.
notes string Free text.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 5, "po_number": "PO-2026-000003", "status": "pending", "total_amount": "26000.00" }
}
POST /purchase-orders/{id}/cancel

Cancel an order still waiting for its goods. A received, paid or closed order is refused (it is returned or closed instead). The key owner needs delete_quotations, as on screen.

Example response

{
  "data": { "id": 5, "po_number": "PO-2026-000003", "status": "cancelled" }
}

Invoices and payments

invoices:write

Draft an invoice, issue it and record what the customer paid. Issuing uses the same step as the Approve button, so eTIMS filing happens at issue where your workspace files. A payment goes through the same balance check and journal posting as one keyed in on screen, and the customer gets the same receipt.

POST /invoices

Create a DRAFT invoice, priced by your price lists. It is placed on credit hold when the customer is over their limit. The key owner needs create_invoices.

Query parameters

NameTypeDescription
customer_id integer Required: an active customer.
invoice_date date Required.
due_date date Optional when the customer has payment terms.
warehouse_id integer Where stock will leave from at checkout.
location_id integer Optional; must belong to the warehouse.
lines array Required: item_id, quantity and an optional discount_value.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 918, "invoice_number": "INV-000918", "status": "draft", "total": "1160.00" }
}
POST /invoices/{id}/issue

Issue a draft invoice. 409 if it is not a draft or is on credit hold. The key owner needs edit_invoices.

Example response

{
  "data": { "id": 918, "invoice_number": "INV-000918", "status": "approved" }
}
POST /invoices/{id}/payments

Record a customer payment against an issued invoice. Refused with a 422 above the balance due. The key owner needs edit_invoices.

Query parameters

NameTypeDescription
amount number Required, > 0 and no more than the balance due.
payment_method string Required, e.g. bank, cash, mpesa, cheque, card.
reference_number string Bank or mobile-money reference.
transaction_id string Your own transaction id.
exchange_rate number Foreign-currency invoices only: the rate on the day the money arrived.
notes string Free text.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 301, "invoice_id": 918, "amount": "500.00", "invoice": { "status": "partially_paid", "balance_due": "660.00" } }
}

Sales orders

sales_orders:write

Take an order and confirm it, through the same actions as the app: price lists, tax and the customer's currency apply, and confirming reserves the stock (what is not on the shelf is backordered) and fires your confirmation workflows. Invoicing, picking and shipping an order stay in AWRA.

POST /sales-orders

Create a DRAFT sales order. The key owner needs manage_sales_orders.

Query parameters

NameTypeDescription
customer_id integer Required: an active customer.
order_date date Required, YYYY-MM-DD.
expected_date date Promised date, on or after order_date.
warehouse_id integer Where the stock will come from.
location_id integer Optional; must belong to the warehouse.
customer_reference string The customer's own PO number.
notes string Free text.
lines array Required, 1–200 rows of item_id or description, quantity, and optional unit_price, discount and tax_exempt.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 41, "order_number": "SO-000041", "status": "draft", "total": "2320.00", "lines": [{ "item_id": "2026101500001", "quantity": 2 }] }
}
POST /sales-orders/{id}/confirm

Confirm a draft order: stock is reserved. 409 if it is not a draft. The key owner needs manage_sales_orders.

Example response

{
  "data": { "id": 41, "order_number": "SO-000041", "status": "confirmed" }
}
GET /sales-orders/{id}

Read a sales order with its lines. Needs sales:read or sales_orders:write.

Example response

{
  "data": { "id": 41, "status": "confirmed", "fulfilment_status": "unfulfilled" }
}

Supplier bills

bills:write

Capture what a supplier billed you and post it. A bill against a purchase order is three-way matched as on screen; posting approves it, so it is owed and its payable reaches the ledger. Whoever entered a bill never posts it, and your value-based approval rules apply (202 while steps remain).

POST /bills

Capture a DRAFT supplier bill. The key owner needs manage_supplier_bills.

Query parameters

NameTypeDescription
vendor_id integer Required.
purchase_order_id integer The order being billed, if any.
supplier_reference string The supplier's invoice number.
bill_date date Required, YYYY-MM-DD.
due_date date Defaults from the supplier's terms.
expense_account_id integer For a bill with no order: the expense account.
withholding_tax_rate_id string Omit for the supplier's rate, "none" for no withholding, or a rate id.
notes string Free text.
lines array Required: description or item_id, quantity, unit_price, tax_rate, and purchase_order_item_id when billing an order.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 203, "bill_number": "BILL-000203", "status": "draft", "total": "11600.00", "balance_due": "11600.00" }
}
POST /bills/{id}/post

Post (approve) a draft or submitted bill. 403 segregation_of_duties if the key owner entered it; 202 while the approval chain has steps left. The key owner needs approve_supplier_bills.

Example response

{
  "data": { "id": 203, "status": "approved", "approved_at": "2026-10-30T12:00:00+03:00" },
  "meta": { "pending_further_approval": false }
}
GET /bills/{id}

Read a supplier bill with its lines and balance. Needs procurement:read, bills:write or payments:write.

Example response

{
  "data": { "id": 203, "status": "partially_paid", "balance_due": "6600.00" }
}

Vendor payments

payments:write

Pay posted supplier bills the way AWRA does: a payment is prepared and submitted, then waits for a second person to approve it (POST /vendor-payments/{id}/decision, approvals:write, never the person who prepared it). Executing an approved payment, which moves the money, stays in AWRA.

POST /vendor-payments

Prepare a payment for one or more posted bills and submit it for approval. The key owner needs manage_payment_runs.

Query parameters

NameTypeDescription
pay_date date Required, YYYY-MM-DD.
name string A label for the payment. Defaults to "API payment <date>".
source_account_id integer The bank or cash account it is paid from.
lines array Required: supplier_bill_id, method (bank, cash, cheque, mpesa…) and an optional amount (defaults to the balance due).

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 12, "reference": "PR-000012", "status": "submitted", "total": "11600.00" }
}
GET /vendor-payments/{id}

Read a vendor payment and its status. Needs procurement:read, payments:write or approvals:write.

Example response

{
  "data": { "id": 12, "status": "approved", "approved_at": "2026-10-30T15:00:00+03:00" }
}

Expense claims

claims:write

A staff expense claim belongs to an employee record: the one linked to the person who created the key. A key whose owner has no employee record is refused with a 422 no_employee_record. Claims go through the claimant's own action, so the currency, decimals and open-project rules match the web; attach receipts in AWRA.

POST /expense-claims

File a claim as a draft, or submit it at once with submit=true. The key owner needs submit_own_expense_claims.

Query parameters

NameTypeDescription
title string Required.
items array Required, 1–50 rows of spent_on (not in the future), category, description, amount and an optional project_id.
submit boolean true submits it for approval straight away. Default false.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 19, "claim_number": "EC-000019", "status": "draft", "total_amount": "3200.00" }
}
POST /expense-claims/{id}/submit

Submit a draft claim of the key owner's for approval. Someone else's claim is a 404.

Example response

{
  "data": { "id": 19, "status": "submitted", "submitted_at": "2026-10-30T09:15:00+03:00" }
}
GET /expense-claims/{id}

Read a claim with its items and status. Needs expenses:read, claims:write or approvals:write.

Example response

{
  "data": { "id": 19, "status": "approved", "reimbursement_method": "payroll" }
}

Expenses

expenses:write

Record an expense through the same path as the expense form. A payable starts pending where your organization requires expense approval, and it posts to the ledger at once (to the first open day if its month is closed).

POST /expenses

Record an expense, as a payable or paid now. The key owner needs record_payments.

Query parameters

NameTypeDescription
category string Required.
amount number Required, tax-inclusive, in your base currency.
expense_date date Required.
settlement string "payable" (owed) or "pay_now" (already paid). Required.
payment_method string Required with pay_now: bank, cash, mpesa, cheque or card.
tax_amount number For reporting.
vendor_id integer Optional supplier.
expense_account_id integer An active expense account; defaults by category.
department_id integer For the P&L by department.
project_id integer An open project.
description string Free text.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 64, "expense_number": "EXP-000064", "status": "pending_approval", "amount": "4500.00", "currency": "KES" }
}
GET /expenses/{id}

Read an expense and its status. Needs expenses:read, expenses:write or approvals:write.

Example response

{
  "data": { "id": 64, "status": "unpaid", "approved_at": "2026-10-30T11:20:00+03:00" }
}

Approval decisions

approvals:write

Approve or reject what is waiting, award quotations and sign orders off for payment, as the person who created the key. This scope is never implied by developer:write; grant it by name. Their permission for that approval is checked, and so are your organization's approval rules, value thresholds and budget blocks. A key can never decide a request its owner raised. That answers 403 segregation_of_duties, whatever the person's role. Each decision needs an Idempotency-Key and is on the audit log against the key and the person.

POST /stock-adjustments/{id}/decision

Approve or reject a pending adjustment. With apply=true an approval also moves the stock and posts the journal (needs adjust_adjustment); otherwise approve needs approve_adjustment and reject needs reject_adjustment.

Query parameters

NameTypeDescription
decision string "approve" or "reject". Required.
apply boolean Approve and apply in one step. Default false.
comment string Recorded in the audit log.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 431, "status": "approved", "approved_by": 12 }
}
POST /stock-transfers/{id}/decision

Approve or reject a pending transfer. Where your organization does not require transfer approval, approving dispatches it, as the app does. Needs adjust_adjustment.

Query parameters

NameTypeDescription
decision string "approve" or "reject". Required.
comment string Recorded in the audit log.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 77, "status": "pending", "approved_at": "2026-10-30T10:02:11+03:00" }
}
POST /purchase-requisitions/{id}/decision

Approve or reject a pending requisition. Answers 202 when your approval chain has more steps; the requisition stays pending. Needs approve_rfq.

Query parameters

NameTypeDescription
decision string "approve" or "reject". Required.
budget_override_reason string Only where your budget control blocks and the owner may override: at least 10 characters.
comment string Recorded in the audit log.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 58, "status": "approved" },
  "meta": { "pending_further_approval": false }
}
POST /expenses/{id}/decision

Approve or reject a pending expense. A rejection reverses its ledger accrual. Needs approve_expense.

Query parameters

NameTypeDescription
decision string "approve" or "reject". Required.
reason string Rejection reason, shown on the expense.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 64, "status": "unpaid", "approved_at": "2026-10-30T11:20:00+03:00" }
}
POST /expense-claims/{id}/decision

Approve or return a submitted staff expense claim. Needs approve_expense_claims.

Query parameters

NameTypeDescription
decision string "approve" or "reject" (returns it to the claimant). Required.
reimbursement_method string Required to approve: "payroll" or "direct".
reimbursement_period string YYYY-MM, required with payroll.
comment string Required to reject: what needs correcting.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 19, "claim_number": "EC-000019", "status": "approved", "reimbursement_method": "payroll", "reimbursement_period": "2026-11" }
}
POST /quotations/{id}/award

Award a quotation: approve the vendor's whole quote and reject the others on its RFQ. Budget blocks, purchase policy and your approval chain apply (202 while steps remain). Refused to whoever raised the requisition behind it. Needs approve_quotations.

Query parameters

NameTypeDescription
budget_override_reason string Only where your budget control blocks and the owner may override: at least 10 characters.
policy_override_reason string Only where purchase policy blocks and the owner may override: at least 10 characters.
comment string Recorded in the audit log.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 88, "quote_number": "Q-000088", "status": "approved" },
  "meta": { "pending_further_approval": false }
}
POST /purchase-orders/{id}/sign-off

Sign a purchase order off for payment once its goods are received. Refused (422) with nothing received, and to whoever raised the order. Needs approve_purchase_orders.

Example response

{
  "data": { "id": 5, "po_number": "PO-2026-000003", "status": "processed" },
  "meta": { "warning": null }
}
POST /vendor-payments/{id}/decision

Approve or send back a submitted vendor payment. Never the person who prepared it. Needs approve_payment_runs. Executing it stays in AWRA.

Query parameters

NameTypeDescription
decision string "approve" or "reject". Required.
reason string Required to reject.

Plus the shared page, per_page, and updated_since parameters.

Example response

{
  "data": { "id": 12, "status": "approved" }
}

Public stock API

A separate, deliberately minimal surface for storefronts and price-check widgets that need stock availability without being trusted with the rest of your data. It predates the Developer API and uses its own credential type and header.

Create a key with the service Public Stock API and the scope stock:read, then send it as X-Public-Token. As with the Developer API, the organization is carried by the key.

EndpointReturns
GET /api/public/stock/{sku} SKU, name, current stock level, item_type and tracks_stock for one item. A service has tracks_stock false and a stock level of 0 that means nothing. 404 if the SKU is unknown.
GET /api/public/items/search Paginated item matches. Requires a query parameter of 2–100 characters.
GET /api/public/categories All item categories as id and name.
curl "https://awraops.com/api/public/stock/SKU-EXAMPLE-001" \
  -H "X-Public-Token: your_public_stock_token"

These endpoints respond in the legacy flat shape rather than the data envelope used by /api/v1. Rate limit: 60 requests per minute.

Stock, money and approvals

  • The key acts as the person who created it. Their permissions and plan apply, and so do your organization’s approval rules, value thresholds and budget blocks. A key whose creator is no longer an active member is refused with key_has_no_owner. Rotating a key makes the person who rotated it the owner.
  • The same actions as the app. Adjustments, transfers, requisitions, orders, invoices, payments and expenses go through the steps the screens use, so a receipt above its order, a location outside its warehouse or a payment above the balance is refused here too.
  • Nothing decides its own request. A key cannot approve or reject anything its owner raised, through any key. Raise with one person’s key and decide with another’s.
  • An Idempotency-Key is required on every one of these POSTs. A retry with the same key and body returns the first result (marked Idempotent-Replayed: true); the same key with a different body is a 409 idempotency_conflict. Keys are remembered for 24 hours.
  • Every write is on your audit log against the key and the person it acted as. The API keys screen in AWRA shows, for each key, the person it acts as.
  • Each area has its own write scope. Invoices take invoices:write, sales orders sales_orders:write, supplier bills bills:write, vendor payments payments:write and expense claims claims:write. Approval decisions need approvals:write by name: the developer:write umbrella does not grant it. Keys created before 30 October 2026 were given the new scopes their old ones covered, so they behave as before.
  • Deleting moves to the trash. DELETE on an item, customer or vendor works only where the app allows it, and the record can be restored. A record with history behind it is a 409 has_dependents. Invoices, orders, bills and requisitions are never deleted: they are cancelled, rejected or reversed.

Inbound webhooks

To push an alert or an event into AWRA OpsHub from an external system — one that should notify people or trigger a workflow — use an inbound webhook. Configure the endpoint and its signing secret in Settings → API & Webhooks.

Notifications and workflow triggers

POST /api/inbound-webhooks

X-Awra-Inbound-Token: your_inbound_token
X-Awra-Signature: sha256=<hmac-sha256 of the raw body, keyed with your secret>
Idempotency-Key: 3f9c1b7a-…

{
  "title":   "Payment received",
  "message": "KES 42,000 settled against INV-2026-000010",
  "url":     "https://your-system.example/payments/8823",
  "type":    "info",
  "event":   "payment.received",
  "data":    { "invoice": "INV-2026-000010", "amount": 42000 }
}

message is required unless you supply event. Naming an event also fires any workflows the organization has configured for it, with data passed through to them; workflows run asynchronously, so the call returns without waiting for them.

Ops Pulse events

For operational telemetry, POST /api/ops-pulse/webhook accepts an X-OpsPulse-Token, an X-OpsPulse-Signature, and a body of event_type and message (both required), plus optional severity, source, and payload.

Signatures and retries

  • If the credential has a signing secret, the signature header is mandatory — a missing or wrong signature is rejected with 401. Compute the HMAC over the exact raw request body, before any reserialization. The sha256= prefix is optional.
  • Send an Idempotency-Key (or X-Awra-Idempotency-Key) so a retry after a timeout cannot create a duplicate. A repeated key replays the original stored response and comes back marked X-Awra-Replayed: true.
  • Both endpoints accept up to 60 requests per minute.

Outbound webhooks

Instead of polling, subscribe a URL to an event and AWRA OpsHub posts to it when the thing happens — whether the record was made on the web, in the mobile app, through an import or by the API. Each subscription is for one event; subscribe the same URL more than once to receive several.

Subscribing

Add a subscription in Settings → API & Webhooks, where you can also set the retry policy and the request timeout and send a test delivery. The same subscription can be created with POST /api/webhooks, listed with GET /api/webhooks and removed with DELETE /api/webhooks/{id}. Those three routes take a signed-in user's token rather than a Developer API key, and the user needs the Manage integrations permission to create or remove one.

POST /api/webhooks

{
  "url":        "https://your-system.example/awra/events",
  "event_type": "invoice.paid",
  "secret":     "a-long-random-string-you-keep"
}

Send your own secret. If you leave it out one is generated, but a secret is never returned by the API afterwards, so you would have nothing to verify against. An event_type outside the list below is refused with 422.

Events

EventWhen it is sentBody
order.shipped Stock transfer completed (order.shipped) The stock transfer row itself, without the envelope — kept as it was for existing receivers.
invoice.created Customer invoice created The envelope: event, occurred_at and data (the record's own columns).
invoice.paid Customer invoice paid in full The envelope: event, occurred_at and data (the record's own columns).
purchase_order.created Purchase order created The envelope: event, occurred_at and data (the record's own columns).
customer.created Customer created The envelope: event, occurred_at and data (the record's own columns).
vendor.created Vendor created The envelope: event, occurred_at and data (the record's own columns).
item.low_stock Item fell to its reorder point The envelope: event, occurred_at and data (the record's own columns).
workflow.webhook Workflow action (sent by a workflow that names this connector) Whatever the workflow action sends. Never fanned out on its own; only a workflow that names this subscription sends it.

What a delivery looks like

POST https://your-system.example/awra/events

Content-Type:     application/json
X-Awra-Event:     invoice.paid
X-Awra-Delivery:  9b2f6c1e-…        (one id per event, the same on every retry)
X-Awra-Timestamp: 1791964800        (Unix seconds, when this attempt was signed)
X-Awra-Signature: sha256=<hex hmac-sha256 of timestamp + "." + raw body>

{
  "event":       "invoice.paid",
  "occurred_at": "2026-10-14T09:30:00+03:00",
  "data":        { … the invoice's own columns … }
}

A delivery is sent after the change is saved, so a save that is rolled back is never announced. Answer with any 2xx to acknowledge it.

Verifying the signature

The signed string is the value of X-Awra-Timestamp, a full stop, and the raw request body — byte for byte, before you parse or reserialize it. The signature is the hex HMAC-SHA256 of that string keyed with your subscription's secret, prefixed sha256=. Compare it in constant time, and reject a timestamp far from your own clock so a captured delivery cannot be replayed later.

<?php
// Verify before you parse. Use the raw body exactly as received.
$body      = file_get_contents("php://input");
$timestamp = $_SERVER["HTTP_X_AWRA_TIMESTAMP"] ?? "";
$received  = $_SERVER["HTTP_X_AWRA_SIGNATURE"] ?? "";

$expected = "sha256=" . hash_hmac("sha256", $timestamp . "." . $body, getenv("AWRA_WEBHOOK_SECRET"));

if (! hash_equals($expected, $received) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}

// Deduplicate on X-Awra-Delivery: a retry carries the same id.

Retries and the dead letter

  • A connection error, a timeout, a 5xx, or a 408, 425 or 429 is retried. Any other 4xx is taken as your answer and is not retried.
  • The subscription's retry policy sets the attempts and the first wait: by default three attempts in all, waiting 30 seconds before the first retry. The wait doubles on each retry, never exceeds one hour, and the attempts never exceed 8.
  • Every retry carries the same X-Awra-Delivery id, with a fresh timestamp and signature. Deduplicate on the id.
  • A delivery that uses up its attempts is kept as a dead letter in the delivery log, the subscription is marked as failing, and the integration failure digest reports it.

Where we will send

  • The URL must be https.
  • A URL that resolves to a private, internal, loopback or reserved address is refused when the subscription is saved, and checked again on every send. The connection is pinned to the address that was checked, so a DNS change between the check and the request cannot move it.
  • Redirects are not followed. A 3xx is recorded as a failed delivery, so point the subscription at the final URL.

What is not part of the public API

AWRA OpsHub's mobile apps and linked barcode scanners talk to a much larger internal HTTP surface. It is authenticated differently — a session token issued to the app at sign-in, not an API key — and it is not a public contract: those routes change whenever the apps change, without notice or a version bump.

Please do not build against it, even if you can observe it. Anything documented on this page is supported and versioned; anything not documented here is not. If you need data or an action this reference does not cover, get in touch — that is a gap worth closing properly rather than working around.

Versioning and change policy

The version is in the path — currently /api/v1. Within a version we will add endpoints, add fields to responses, and add optional parameters. We will not remove or rename a field, change a field's type, or change what an existing parameter does.

So write a tolerant client: ignore fields you do not recognize rather than failing on them. Anything that would break a well-behaved client ships as /api/v2, with the previous version kept running while you migrate.

Changes in v1

  • 2026-10-30 More writes, each under its own scope: sales orders (sales_orders:write), supplier bills (bills:write), vendor payments prepared for approval (payments:write), staff expense claims (claims:write), cancelling a purchase order (purchasing:write), and quotation award, purchase-order sign-off and vendor-payment decisions (approvals:write). DELETE /items/{id}, /customers/{id} and /vendors/{id} move a record to the trash where the app allows it. Scope change: invoices moved from sales:write to the new invoices:write, and approvals:write is no longer implied by developer:write. Every existing key that held sales:write was granted invoices:write, and every key that held developer:write was granted approvals:write, so no integration changes behaviour.
  • 2026-10-30 Stock, money and approval writes: stock adjustments and transfers (stock:write), requisitions and purchase orders from approved quotations (purchasing:write), draft, issue and record payments on invoices (sales:write), expenses (expenses:write), and approval decisions on all of them plus expense claims (approvals:write). They act as the person who created the key, through the same actions as the app, and need an Idempotency-Key. Every error is now an RFC 7807 application/problem+json document; it still carries the error.code and error.message members, so existing clients keep working.
  • 2026-10-29 Items gain is_active and deactivated_at, /items accepts a status filter (active, inactive; every item when omitted), and POST and PATCH /items accept is_active. An inactive item is discontinued: it keeps its history but is hidden from new work, and making one inactive is refused with a 422 while it still has stock or an open hold. low_stock no longer returns inactive items.
  • 2026-10-15 Writes: POST and PATCH for /items, /customers and /vendors, under the new inventory:write, sales:write, procurement:write and developer:write scopes. Existing keys are unchanged — developer:read and "*" still read everything and never write.
  • 2026-10-02 Items gain item_type (stock or service) and tracks_stock, and /items accepts an item_type filter. A service carries no stock: its tracks_stock is false and its stock, reorder_point and safety_stock are 0, so read tracks_stock before treating a 0 as sold out. low_stock never returns a service.

Examples

Page through every unpaid invoice — PHP

<?php

$key  = getenv("AWRA_API_KEY");
$page = 1;

do {
    $url = "https://awraops.com/api/v1/invoices?unpaid=true&per_page=100&page={$page}";

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => ["Authorization: Bearer {$key}", "Accept: application/json"],
    ]);
    $body = json_decode(curl_exec($ch), true);
    curl_close($ch);

    foreach ($body["data"] as $invoice) {
        echo $invoice["invoice_number"], " owes ", $invoice["balance_due"], PHP_EOL;
    }

    $page++;
} while ($page <= $body["meta"]["total_pages"]);

Incremental stock sync — JavaScript

const res = await fetch(
  "https://awraops.com/api/v1/items?updated_since=" + encodeURIComponent(lastRunIso),
  { headers: { Authorization: "Bearer " + process.env.AWRA_API_KEY, Accept: "application/json" } }
);

if (res.status === 429) throw new Error("Rate limited — back off and retry");
if (!res.ok) throw new Error((await res.json()).error.message);

const { data, meta } = await res.json();
console.log(data.length + " of " + meta.total + " items changed since " + lastRunIso);

Low stock check — cURL

curl "https://awraops.com/api/v1/items?low_stock=true&per_page=100" \
  -H "Authorization: Bearer $AWRA_API_KEY" \
  -H "Accept: application/json"

Need something this reference does not cover?

The Developer API reads inventory, sales and procurement and writes their master data, and the eight outbound events above cover the records most integrations watch. If you need another module, another write operation, or another event, tell us what you are building — it helps us prioritise what to open up next.