APB360 API

JSON over HTTPS, with a realtime websocket channel for events from the main system.

Overview

This is the next generation endpoint for apb360.com. It replaces the WordPress-based API at api.guntrac.com, keeping the same user accounts and the same permission model, but without loading WordPress on every request.

Base URLhttps://api-next.apb360.com
Websocketwss://api-next.apb360.com/ws
Content typeapplication/json for every request and response
AuthWordPress session cookie, or a bearer token issued by this API
Everything is versioned under /v1/. Paths outside it — /health, /ws, and this page — are infrastructure, not API surface, and are not covered by the version contract.

Quickstart

Three calls: prove who you are, get a token, use it.

# 1. Exchange your WordPress session for a token pair.
#    --cookie-jar from a prior portal login, or send the cookie directly.
curl -sS -X POST https://api-next.apb360.com/v1/auth/token \
     -b "wordpress_logged_in_<hash>=<cookie value>"

# {
#   "token_type": "Bearer",
#   "access_token": "eyJ0eXAiOi...",
#   "refresh_token": "eyJ0eXAiOi...",
#   "expires_in": 900,
#   "identity": { "user_id": 7, "login": "...", "capabilities": [...] }
# }

# 2. Call the API with it.
curl -sS https://api-next.apb360.com/v1/session \
     -H "Authorization: Bearer $ACCESS_TOKEN"

# 3. When it expires after 15 minutes, refresh.
curl -sS -X POST https://api-next.apb360.com/v1/auth/refresh \
     -H 'Content-Type: application/json' \
     -d '{"refresh_token":"'"$REFRESH_TOKEN"'"}'

Authentication

There are two ways in. They produce the same identity and the same permissions; which one you use depends on what kind of client you are.

WordPress session cookie

If the caller is a browser already signed in to the APB360 portal, the API verifies that session cookie directly. It checks the HMAC, the expiration, and that the session has not been logged out — the same three things WordPress checks, without booting WordPress.

Cross-subdomain caveat. A browser only sends a cookie to hosts inside its cookie domain. The portal's session cookie is not currently scoped to reach this host, so in practice the cookie path works server-to-server, and browser clients should use a bearer token obtained by your backend.

Bearer token

Everything else — mobile apps, IoT scanners, backend services, and the websocket — uses Authorization: Bearer <token>. Tokens are signed with Ed25519 and verified without a database lookup.

Tokens

TypeLifetimeUse
access15 minutes Normal API calls. Carries your capabilities.
refresh30 days Exchange for a new access token. Carries no permissions on its own.
ws_ticket30 seconds, single use Opens exactly one websocket connection.

Access tokens are short-lived on purpose. Your capabilities are baked into the token so the API does not have to re-read them on every call, which means a permission change in the portal takes up to 15 minutes to take effect. Redeeming a refresh token re-reads permissions from the database, so a refresh applies them immediately.

Capabilities

Authorization is the same model the portal uses: WordPress roles plus the Groups plugin. Your effective permissions are the union of both, and are listed in identity.capabilities on every token response and from GET /v1/session.

An endpoint that needs a capability you do not hold returns 403 missing_capability naming the one required.

Organization scope is implicit. Endpoints that return organization data derive your organization from your user account. There is no org_id parameter to pass, and supplying one has no effect.

Endpoints

EndpointAuthDescription
GET /health noneLiveness. Does not touch the database.
GET /health/ready noneReadiness. Verifies the database is reachable.
POST /v1/auth/token cookieMint an access + refresh pair.
POST /v1/auth/refresh refresh tokenNew access token; permissions re-read.
POST /v1/auth/ws-ticket any30-second ticket for one websocket connection.
GET /v1/session anyYour identity and capabilities.
GET /v1/org/assets read_inventory Assets for your organization. limit (1–500, default 50) and offset.
POST /v1/events/publish publish_events Broadcast an event to your organization's websocket subscribers.
POST /v1/events/echo any Send an event to your own topic. For testing the round trip.
POST /v1/auth/login — Sign in with my.apb360.com credentials.

The ATF FFL Direct endpoints are listed separately under ATF FFL Direct — they have a different caller and a different credential.

Errors

Every error has the same shape, and code is the part to branch on — the prose in detail may change.

{
  "error": {
    "code":   "missing_capability",
    "detail": "This endpoint requires the 'read_inventory' capability.",
    "status": 403
  }
}
StatusCodeMeaning
400invalid_type, invalid_payloadMalformed request body.
401not_authenticatedNo valid cookie or token. Refresh, then retry.
401invalid_refresh_tokenRefresh token expired or revoked. Re-authenticate.
403missing_capabilityAuthenticated, but not permitted.
403no_organizationYour account is not a member of any organization.
429—Rate limited. Back off and retry.
503database_unavailableReadiness probe only.

A 401 means "we do not know who you are"; a 403 means "we do, and you may not". They are deliberately distinct so retry logic does not have to guess.

Rate limits

PathSustainedBurst
/v1/auth/*2 req/s5
/api/* (mobile)30 req/s100
everything else30 req/s50
/ws20 concurrent connections per client

Limits are per client IP. Exceeding one returns 429 with no body. Auth is tighter because it is the expensive path and the one worth guessing against.

Mobile asset API

Serial registration, RFID tag binding, cases and asset events, for the iOS scanning client. Eight endpoints under /api/. Sign in with POST /v1/auth/login and send the bearer token on every call.

Every response names a serial number serial_number, never serial. Requests should send serial_number too; serial is still accepted as the old name, and serial_number wins if both are sent.

/api/* uses a different response shape from /v1/*. Every response an /api endpoint produces is wrapped as {"status":200,"data":{…},"error":""}, success or failure. The exceptions are failures raised before the endpoint runs: 401, 403, 405, 500 and an unknown path's 404 use the /v1 error shape, and 429 comes from the web server. Read the HTTP status first.
EndpointDescription
GET /api/assets/decode Decode a scan: serial number, RFID uuid or case code.
GET /api/assets/trace Full trace of one asset, serialized or not: A&D, locations, custody, events, cases, documents.
POST /api/assets/register Reserve a block of serials from a prefix and register them.
POST /api/tags/register Bind a printed RFID tag to a serial.
POST /api/tags/status Set label status: Minted, Pending Print, Printed, Re-Printed.
GET /api/system_settings Locations, event types, and the manufacturer / model / type / caliber / length dropdowns.
POST /api/asset/event Record an event against scanned assets.
POST /api/case/register Mint case labels.
POST /api/case/assign Scan assets into a case.
POST /api/case/event Record an event against everything in a case.

Everything is scoped to the signed-in user's FFL automatically. The server resolves the user to their organization (for example user 207 → FFL_ORG_ID XXXX). No endpoint takes an organization id, none returns one, and another FFL's serial returns 404.

Device location. Any /api request may send the device's location, using CLLocation's values as-is:

X-Latitude: 36.3728538             (location.coordinate.latitude)
X-Longitude: -94.2088172           (location.coordinate.longitude)
X-Location-Accuracy: 12.5          (location.horizontalAccuracy, metres, optional)

Geo-Position: 36.3728538;-94.2088172 epu=12.5 also works. Every asset the request touches records it as its last-known location, and event and tag-status log rows record it too. Location is optional and never causes an error: unusable values, including a negative horizontalAccuracy, are ignored. Timestamps are ISO 8601 UTC, e.g. 2026-10-15T14:02:51Z.

The examples below show the full request and response. Values are illustrative.

GET /api/assets/decode

Pass exactly one of serial_number, uuid or case_id. The scanner does not need to know which kind it read.

GET /api/assets/decode?serial_number=A56N01006
Authorization: Bearer eyJ0eXAi...
200 OK
{
  "status": 200,
  "data": {
    "ffl_serial_id": 1443201,
    "serial_number": "A56N01006",
    "uuid": "E20020626314020619304B29",
    "manufacturer": "GLOCK",
    "model": "G19",
    "caliber": "9mm",
    "length": "4.0",
    "type": null,
    "status": "New",
    "status_id": 1,
    "case_id": "case-7k2m9x",
    "tag_status": { "status_id": 3, "name": "Printed", "last_update": "2026-10-15T14:02:51Z" },
    "last_location": { "latitude": 36.3728538, "longitude": -94.2088172, "accuracy": 12.5, "recorded_at": "2026-10-15T14:05:10Z" },
    "matched_on": "serial_number"
  },
  "error": ""
}

A case code decodes to the box and its contents:

GET /api/assets/decode?case_id=case-7k2m9x
Authorization: Bearer eyJ0eXAi...
200 OK
{
  "status": 200,
  "data": {
    "matched_on": "case_id",
    "case_id": "case-7k2m9x",
    "status": "OPEN",
    "status_id": 1,
    "created_at": "2026-10-13 14:02:51",
    "assets": [
      { "serial_number": "A56N01006", "uuid": "E20020626314020619304B29", "quantity": 1, "asset_id": 563873,
        "tag_status": { "status_id": 3, "name": "Printed", "last_update": "2026-10-15T14:02:51Z" } },
      { "serial_number": "A56N01007", "uuid": "E20020626314020619304B2A", "quantity": 1, "asset_id": 563874,
        "tag_status": { "status_id": 1, "name": "Minted", "last_update": "2026-10-15T13:58:40Z" } }
    ]
  },
  "error": ""
}
404 Not Found
{ "status": 404, "data": "", "error": "Nothing registered matches 'NOSUCHTHING'." }

400 Bad Request
{ "status": 400, "data": "", "error": "Provide serial_number, uuid or case_id." }

GET /api/assets/trace

Everything on record for one asset, serialized or not, with the current state and one timeline that merges it all in date order. Read-only: a trace never updates the last-known location. Pass one identifier:

ParameterMatches
identifierWhatever was scanned or typed: tried as a serial number, a barcode and a UUID / RFID. matched_on says which matched.
serial_numberEvery serial record with that number in your FFL.
barcodeThe asset's barcode, where one is configured.
uuid / rfidThe tag's UUID (the two names are the same parameter). A UUID belongs to one serial number; see 409 below.
asset_idThe asset id other responses return.

Matching ignores letter case. The report holds the serial and asset records, quantity on hand, the current A&D records (nothing marked in error or corrected), attribute history, the active locations it is at now, custody, the inventory ledger and legacy transfer log, events, tag status history, cases, documents (names and dates, never content), inventory checks, repairs, supplemental types, the timeline, and users, which maps every *_by user id to a name. Lists are oldest first; each holds at most 1,000 rows, the newest, and a list that hit the limit is named in truncated. Values below are illustrative.

A firearm, by serial number

GET /api/assets/trace?serial_number=FERR0108
Authorization: Bearer eyJ0eXAi...
200 OK
{
  "status": 200,
  "data": {
    "matched_on": ["serial_number"],
    "query": "FERR0108",
    "kind": "serialized",
    "generated_at": "2026-10-18T19:04:46Z",
    "serials": [
      { "serial_id": 1443194, "serial_number": "FERR0108", "uuid": null, "encoded_uuid": null,
        "manufacturer": "GLOCK", "model": "G17", "type": "Pistol", "caliber": "9mm", "length": null,
        "status": "New", "status_id": 1, "created_at": null, "last_update": "2026-01-22T18:20:11Z",
        "created_by": 207, "details": {},
        "tag_status": { "status_id": 3, "name": "Printed", "last_update": "2026-10-15T14:02:51Z" },
        "last_location": { "latitude": 36.3728538, "longitude": -94.2088172, "accuracy": 12.5, "recorded_at": "2026-10-15T14:05:10Z" } }
    ],
    "assets": [
      { "asset_id": 550277, "kind": "serialized", "serial_id": 1443194, "barcode": null,
        "description": null, "category": null, "sku": null, "lot": null, "internal_id": null,
        "manufacturer": null, "model": null, "caliber": null, "lot_expires_at": null,
        "ownership": "department", "is_gift": false, "disabled": false, "value": null,
        "class_code": null, "account": null, "agency": null, "expires_at": null,
        "reconciled_at": null, "reconciled_by": [], "created_by": 207, "last_update": "2026-01-22T18:20:11Z" }
    ],
    "quantity_on_hand": { "locations": 1, "employees": 0, "total": 1 },
    "acquisitions_dispositions": [
      { "record": "acquisition", "id": 2420001, "serial_id": 1443194, "transfer_id": 3700001,
        "prior_transfer_id": null, "event_date": "2026-01-22T18:20:11Z",
        "transfer_type": "Acquisition/Check In from FFL", "transfer_type_id": 1, "reason": "Purchase",
        "party": { "license_number": "9-86-XXX-XX-XX-XXXXX", "business_name": "Example Distributor",
                   "employee_name": null, "individual_name": null, "drivers_license": null, "ccw_permit": null,
                   "address_1": "1 Main St", "address_2": null, "city": "Phoenix", "state": "AZ",
                   "zipcode": "85027", "country": "United States", "phone_number": null, "email": null },
        "references": { "form_4473": null, "nics": null, "invoice": "INV-1001", "purchase_order": null,
                        "tracking": null, "expected_return": null },
        "note": null, "recorded_by": 207,
        "disposition_id": null, "reconciled_at": null, "reconciled_by": null }
    ],
    "attribute_history": [
      { "history_id": 6100001, "serial_id": 1443194, "serial_number": "FERR0108", "action": "INSERT",
        "changed_at": "2026-01-22T18:20:11Z", "changed_by": 207, "manufacturer": "GLOCK", "model": "G17",
        "type": "Pistol", "caliber": "9mm", "length": null, "status": "New" }
    ],
    "locations": [
      { "asset_id": 550277, "location_id": 1201, "location": "Main Armory", "location_type": "Stocking Location",
        "location_disabled": false, "quantity": 1, "since": "2026-01-22T18:20:11Z", "expires_at": null, "updated_by": 207 }
    ],
    "custody": [],
    "inventory_movements": [
      { "id": 90001, "asset_id": 550277, "at": "2026-10-17T00:00:00Z", "reason": "opening_balance",
        "note": "Counts when the inventory ledger started", "source": "migration 008", "reference": null,
        "recorded_by": null,
        "lines": [ { "holder": "location", "location_id": 1201, "location": "Main Armory", "delta": 1, "balance_after": 1 } ],
        "total_after": { "all": 1, "locations": 1, "employees": 0 },
        "authorization_file_id": null, "employee_file_id": null }
    ],
    "transfer_log": [],
    "events": [
      { "id": 41, "event": "Shipping EOD", "event_id": 2, "at": "2026-10-15T14:05:10Z", "serial_id": 1443194,
        "asset_id": null, "serial_number": "FERR0108", "uuid": null, "case_id": "case-7k2m9x",
        "location_id": null, "location": null, "batch_id": "6f1c2a9e-0b7d-4c1e-9a3f-2d5e8b7c4a10",
        "recorded_by": 207,
        "device_location": { "latitude": 36.3728538, "longitude": -94.2088172, "accuracy": 12.5, "recorded_at": "2026-10-15T14:05:10Z" } }
    ],
    "tag_status_history": [
      { "id": 17, "serial_id": 1443194, "at": "2026-10-15T14:02:51Z",
        "from": { "status_id": 2, "name": "Pending Print" }, "to": { "status_id": 3, "name": "Printed" },
        "uuid": null, "recorded_by": 207, "device_location": null }
    ],
    "cases": [
      { "case_id": "case-7k2m9x", "current": true, "status": "OPEN", "status_id": 1, "asset_id": 550277,
        "serial_id": 1443194, "quantity": 1, "assigned_at": "2026-10-15T14:04:02Z", "assigned_by": 207,
        "case_location": null, "case_note": null, "case_created_at": "2026-10-13T14:02:51Z", "case_created_by": 207 },
      { "case_id": "case-ab12cd", "current": false, "first_event_at": "2026-10-01T10:00:00Z",
        "last_event_at": "2026-10-01T10:00:00Z", "events": 1 }
    ],
    "documents": [
      { "file_id": 925984, "file_name": "invoice-1001.pdf", "storage_key": "69e7b50dee61e4.43301997-invoice-1001.pdf",
        "document_type": "Purchase Order", "mime_type": "application/pdf", "size": "232283", "status": null,
        "control_number": "000111", "note": null, "etag": "62252bed6b97cf49fc769b88b6a59b21-1",
        "uploaded_at": "2026-06-24T14:47:02Z", "uploaded_by": 207, "submitted_at": "2026-06-24T00:00:00Z",
        "approved_at": null, "rejected_at": null, "expires_at": null,
        "linked_to": [ { "via": "serial", "ref_id": 1443194, "linked_at": "2026-06-24T14:47:02Z", "linked_by": 207 } ] }
    ],
    "inventory_checks": { "current": [], "history": [] },
    "repairs": [],
    "supplemental_types": [],
    "timeline": [
      { "at": "2026-01-22T18:20:11Z", "type": "acquisition", "summary": "Acquired from Example Distributor - Purchase",
        "section": "acquisitions_dispositions", "id": 2420001 },
      { "at": "2026-10-15T14:05:10Z", "type": "event", "summary": "Shipping EOD (case case-7k2m9x)",
        "section": "events", "id": 41 }
    ],
    "users": { "207": "Johnny Law" },
    "truncated": []
  },
  "error": ""
}

A scanned barcode on a non-serialized item

identifier matched a barcode. Nine boxes are on a shelf (locations) and one is issued to an employee (custody); there are no serial, A&D or tag sections for a non-serialized item.

GET /api/assets/trace?identifier=0048400000022
Authorization: Bearer eyJ0eXAi...
200 OK
{
  "status": 200,
  "data": {
    "matched_on": ["barcode"],
    "query": "0048400000022",
    "kind": "non_serialized",
    "generated_at": "2026-10-19T13:10:04Z",
    "serials": [],
    "assets": [
      { "asset_id": 472373, "kind": "non_serialized", "serial_id": null, "barcode": "0048400000022",
        "description": "9mm 124gr JHP, box of 50", "category": "Ammunition", "sku": "9JHP124-50", "lot": "L2409",
        "internal_id": null, "manufacturer": "Federal", "model": null, "caliber": "9mm", "lot_expires_at": null,
        "ownership": "department", "is_gift": false, "disabled": false, "value": 32.5,
        "class_code": null, "account": null, "agency": null, "expires_at": null,
        "reconciled_at": null, "reconciled_by": [], "created_by": 207, "last_update": "2024-09-26T17:01:59Z" }
    ],
    "quantity_on_hand": { "locations": 9, "employees": 1, "total": 10 },
    "acquisitions_dispositions": [],
    "attribute_history": [],
    "locations": [
      { "asset_id": 472373, "location_id": 2395, "location": "Armory", "location_type": "Stocking Location",
        "location_disabled": false, "quantity": 9, "since": "2024-10-08T15:19:03Z", "expires_at": null, "updated_by": 207 }
    ],
    "custody": [
      { "asset_id": 472373, "employee": { "employee_id": "01", "name": "Adam Demo", "badge_number": "S01", "retired": false },
        "quantity": 1, "since": "2024-09-26T17:03:52Z", "expires_at": null, "note": null, "updated_by": 207 }
    ],
    "inventory_movements": [
      { "id": 53646, "asset_id": 472373, "at": "2026-10-17T16:43:18Z", "reason": "opening_balance",
        "note": "Counts when the inventory ledger started", "source": "migration 008", "reference": null,
        "recorded_by": null,
        "lines": [
          { "holder": "location", "location_id": 2395, "location": "Armory", "delta": 9, "balance_after": 9 },
          { "holder": "employee", "employee": { "employee_id": "01", "name": "Adam Demo", "badge_number": "S01", "retired": false },
            "delta": 1, "balance_after": 1 }
        ],
        "total_after": { "all": 10, "locations": 9, "employees": 1 },
        "authorization_file_id": null, "employee_file_id": null }
    ],
    "transfer_log": [
      { "id": 49748, "asset_id": 472373, "at": "2024-09-26T17:02:00Z", "purpose": "Add To Inventory",
        "operand": "+", "quantity": 10, "note": "Adding Inventory to Location Id 2395", "recorded_by": 207,
        "coordinates": null, "authorization_file_id": 808468, "employee_file_id": null },
      { "id": 49750, "asset_id": 472373, "at": "2024-09-26T17:03:52Z", "purpose": "Duty Issue",
        "operand": "-", "quantity": 1, "note": "Subtracting Inventory from Location for assignment to Employee",
        "recorded_by": 207, "coordinates": null, "authorization_file_id": 808474, "employee_file_id": 808473 }
    ],
    "events": [],
    "tag_status_history": [],
    "cases": [],
    "documents": [
      { "file_id": 808468, "file_name": "885513image.png", "storage_key": "-XXXX-885513image.png-1727370119",
        "document_type": "Authorizing Signature", "mime_type": "image/png", "size": "6209", "status": null,
        "control_number": null, "note": null, "etag": "3a248335b7e78166cada3fc5a5d7898f",
        "uploaded_at": "2024-09-26T17:01:59Z", "uploaded_by": 207, "submitted_at": null,
        "approved_at": null, "rejected_at": null, "expires_at": null,
        "linked_to": [ { "via": "transfer_log_authorization", "ref_id": 49748, "linked_at": "2024-09-26T17:02:00Z", "linked_by": 207 } ] },
      { "file_id": 808473, "file_name": "839591image.png", "storage_key": "-XXXX-839591image.png-1727370231",
        "document_type": "Accepting Signature", "mime_type": "image/png", "size": "5980", "status": null,
        "control_number": null, "note": null, "etag": "9f1d0c6e2b7a4d3e8c5b1a0f6e2d4c7b",
        "uploaded_at": "2024-09-26T17:03:51Z", "uploaded_by": 207, "submitted_at": null,
        "approved_at": null, "rejected_at": null, "expires_at": null,
        "linked_to": [ { "via": "transfer_log_employee", "ref_id": 49750, "linked_at": "2024-09-26T17:03:52Z", "linked_by": 207 } ] }
    ],
    "inventory_checks": { "current": [], "history": [] },
    "repairs": [],
    "supplemental_types": [],
    "timeline": [
      { "at": "2024-09-26T17:01:59Z", "type": "document", "summary": "Authorizing Signature 885513image.png",
        "section": "documents", "id": 808468 },
      { "at": "2024-09-26T17:02:00Z", "type": "inventory_transfer", "summary": "Add To Inventory +10",
        "section": "transfer_log", "id": 49748 },
      { "at": "2024-09-26T17:03:51Z", "type": "document", "summary": "Accepting Signature 839591image.png",
        "section": "documents", "id": 808473 },
      { "at": "2024-09-26T17:03:52Z", "type": "inventory_transfer", "summary": "Duty Issue -1",
        "section": "transfer_log", "id": 49750 },
      { "at": "2026-10-17T16:43:18Z", "type": "inventory_movement", "summary": "opening balance: +9 Armory, +1 Adam Demo",
        "section": "inventory_movements", "id": 53646 }
    ],
    "users": { "207": "Johnny Law" },
    "truncated": []
  },
  "error": ""
}

An RFID read

A serialized item issued to an employee. The acquisition names the disposition that closed it (disposition_id) and the disposition names the acquisition (acquisition_id).

GET /api/assets/trace?rfid=2401C1C2C3C4C5C6C7C12849
Authorization: Bearer eyJ0eXAi...
200 OK
{
  "status": 200,
  "data": {
    "matched_on": ["uuid"],
    "query": "2401C1C2C3C4C5C6C7C12849",
    "kind": "serialized",
    "generated_at": "2026-10-19T13:12:40Z",
    "serials": [
      { "serial_id": 650683, "serial_number": "DD-011", "uuid": "2401C1C2C3C4C5C6C7C12849", "encoded_uuid": null,
        "manufacturer": "ALS TECHNOLOGIES", "model": "ALSG101CS", "type": "Destructive Device",
        "caliber": "Diversionary Device", "length": null, "status": "New", "status_id": 1,
        "created_at": null, "last_update": "2017-02-15T19:02:35Z", "created_by": null, "details": {},
        "tag_status": null, "last_location": null }
    ],
    "assets": [
      { "asset_id": 56865, "kind": "serialized", "serial_id": 650683, "barcode": null,
        "description": null, "category": null, "sku": null, "lot": null, "internal_id": null,
        "manufacturer": null, "model": null, "caliber": null, "lot_expires_at": null,
        "ownership": "department", "is_gift": false, "disabled": false, "value": null,
        "class_code": null, "account": null, "agency": null, "expires_at": null,
        "reconciled_at": null, "reconciled_by": [], "created_by": 207, "last_update": "2017-01-09T19:04:47Z" }
    ],
    "quantity_on_hand": { "locations": 0, "employees": 1, "total": 1 },
    "acquisitions_dispositions": [
      { "record": "acquisition", "id": 1167843, "serial_id": 650683, "transfer_id": 2224654,
        "prior_transfer_id": null, "event_date": "2017-01-09T19:04:47Z",
        "transfer_type": "Simple Acquisition", "transfer_type_id": 0, "reason": "Inventory",
        "party": { "license_number": "Simple Acquisition", "business_name": null, "employee_name": null,
                   "individual_name": null, "drivers_license": null, "ccw_permit": null,
                   "address_1": null, "address_2": null, "city": null, "state": null,
                   "zipcode": null, "country": null, "phone_number": null, "email": null },
        "references": { "form_4473": null, "nics": null, "invoice": null, "purchase_order": null,
                        "tracking": null, "expected_return": null },
        "note": "Size: 3.125 inches in diameter and 5.25 inches in height", "recorded_by": 207,
        "disposition_id": 1586049, "reconciled_at": null, "reconciled_by": null },
      { "record": "disposition", "id": 1586049, "serial_id": 650683, "transfer_id": 3139768,
        "prior_transfer_id": null, "event_date": "2017-09-18T20:21:11Z",
        "transfer_type": "Disposition to Employee", "transfer_type_id": 7, "reason": "Duty Issue",
        "party": { "license_number": null, "business_name": null, "employee_name": "Sean Demo",
                   "individual_name": null, "drivers_license": null, "ccw_permit": null,
                   "address_1": null, "address_2": null, "city": null, "state": null,
                   "zipcode": null, "country": "United States", "phone_number": null, "email": null },
        "references": { "form_4473": null, "nics": null, "invoice": null, "purchase_order": null,
                        "tracking": null, "expected_return": null },
        "note": null, "recorded_by": 207,
        "acquisition_id": 1167843, "reconciled_at": null, "reconciled_by": null }
    ],
    "attribute_history": [
      { "history_id": 2984735, "serial_id": 650683, "serial_number": "DD-011", "action": "INSERT",
        "changed_at": "2017-01-09T19:04:47Z", "changed_by": 207, "manufacturer": "ALS TECHNOLOGIES",
        "model": "ALSG101CS", "type": "Destructive Device", "caliber": "Diversionary Device",
        "length": null, "status": "New" }
    ],
    "locations": [],
    "custody": [
      { "asset_id": 56865, "employee": { "employee_id": "11247", "name": "Sean Demo", "badge_number": "B41700", "retired": false },
        "quantity": 1, "since": "2017-09-18T20:21:11Z", "expires_at": null, "note": "Standard Operations", "updated_by": 207 }
    ],
    "inventory_movements": [],
    "transfer_log": [],
    "events": [],
    "tag_status_history": [],
    "cases": [],
    "documents": [],
    "inventory_checks": { "current": [], "history": [] },
    "repairs": [],
    "supplemental_types": [],
    "timeline": [
      { "at": "2017-01-09T19:04:47Z", "type": "acquisition", "summary": "Acquired from Simple Acquisition - Inventory",
        "section": "acquisitions_dispositions", "id": 1167843 },
      { "at": "2017-01-09T19:04:47Z", "type": "attributes_changed",
        "summary": "Serial record INSERT: ALS TECHNOLOGIES ALSG101CS Destructive Device Diversionary Device",
        "section": "attribute_history", "id": 2984735 },
      { "at": "2017-09-18T20:21:11Z", "type": "disposition", "summary": "Disposed to Sean Demo - Duty Issue",
        "section": "acquisitions_dispositions", "id": 1586049 }
    ],
    "users": { "207": "Johnny Law" },
    "truncated": []
  },
  "error": ""
}

Errors

404 Not Found
{ "status": 404, "data": "", "error": "Nothing registered matches 'NOSUCHTHING'." }

409 Conflict   (the UUID is on more than one serial number; fix the tag, then trace again)
{ "status": 409, "data": "", "error": "UUID '150311143630000000009650' is on more than one serial number (GLOCK0093LEO, 121212). Each tag belongs to one serial; correct the tag assignment, then trace again." }

400 Bad Request
{ "status": 400, "data": "", "error": "Provide identifier, serial_number, barcode, uuid (or rfid) or asset_id." }

More samples (a value matching two ways, asset_id, curl, Swift and jq recipes) are in docs/mobile-api.md.

POST /api/assets/register

The server mints the serials: send a prefix and a quantity, print from the list that comes back. Numbering continues from the highest serial the organization already holds for that prefix, at the same width. Only serial_number_prefix and quantity (1–500) are required. manufacturer, model, caliber and length are names, and unknown ones are created.

POST /api/assets/register
Authorization: Bearer eyJ0eXAi...
Content-Type: application/json

{
  "serial_number_prefix": "A56N",
  "quantity": 3,
  "manufacturer": "Surefire",
  "model": "X300",
  "caliber": "9mm",
  "length": "4.0"
}
201 Created
{
  "status": 201,
  "data": {
    "serial_numbers": ["A56N01006", "A56N01007", "A56N01008"],
    "quantity": 3,
    "prefix": "A56N",
    "tag_status": { "status_id": 1, "name": "Minted" }
  },
  "error": ""
}
400 Bad Request
{ "status": 400, "data": "", "error": "serial_number_prefix is required." }
{ "status": 400, "data": "", "error": "prefix must be 1-32 characters of A-Z, 0-9 or hyphen." }
{ "status": 400, "data": "", "error": "quantity must be between 1 and 500." }
{ "status": 400, "data": "", "error": "Reserving 3 would overflow the 5-digit suffix for prefix A56N (last reserved 99998). Use a new prefix." }

POST /api/tags/register

The first scan after printing: bind an RFID tag to a serial. Up to 500 per call. Partial success is normal. The call returns 200, and each tag reports its own ok, so check results. rewritten: true means the serial previously carried a different tag.

POST /api/tags/register
Authorization: Bearer eyJ0eXAi...
Content-Type: application/json

{
  "tags": [
    { "uuid": "E20020626314020619304B29", "serial_number": "A56N01006" },
    { "uuid": "E20020626314020619304B2A", "serial_number": "A56N01007" },
    { "uuid": "E20020626314020619304B2B", "serial_number": "A56N09999" },
    { "uuid": "E20020626314020619304B29", "serial_number": "A56N01008" }
  ]
}
200 OK
{
  "status": 200,
  "data": {
    "registered": 2,
    "results": [
      { "serial_number": "A56N01006", "uuid": "E20020626314020619304B29", "ok": true,  "rewritten": false,
        "tag_status": { "status_id": 3, "name": "Printed", "last_update": "2026-10-15T14:02:51Z" } },
      { "serial_number": "A56N01007", "uuid": "E20020626314020619304B2A", "ok": true,  "rewritten": true,
        "tag_status": { "status_id": 1, "name": "Minted", "last_update": "2026-10-15T13:58:40Z" } },
      { "serial_number": "A56N09999", "uuid": "E20020626314020619304B2B", "ok": false, "error": "serial number is not registered" },
      { "serial_number": "A56N01008", "uuid": "E20020626314020619304B29", "ok": false, "error": "tag is already on serial A56N01006" }
    ]
  },
  "error": ""
}
400 Bad Request
{ "status": 400, "data": "", "error": "tags must be a non-empty array of {uuid, serial_number}." }
{ "status": 400, "data": "", "error": "At most 500 tags per request." }

POST /api/tags/status

Set the label status of scanned tags. Minted (1) is set automatically when serials are registered; the app reports Pending Print (2), Printed (3) and Re-Printed (4). Send status_id, or status by name. Each tag is a serial_number or a uuid. Every change is logged with the previous status, who changed it, when, and where. Up to 500 per call; partial success works as it does for tags.

POST /api/tags/status
Authorization: Bearer eyJ0eXAi...
Content-Type: application/json
X-Latitude: 36.3728538
X-Longitude: -94.2088172

{
  "status_id": 3,
  "tags": [
    { "serial_number": "A56N01006" },
    { "uuid": "E20020626314020619304B2A" },
    { "serial_number": "A56N09999" }
  ]
}
200 OK
{
  "status": 200,
  "data": {
    "updated": 2,
    "results": [
      { "serial_number": "A56N01006", "uuid": "E20020626314020619304B29", "ok": true,
        "tag_status": { "status_id": 3, "name": "Printed", "last_update": "2026-10-15T14:02:51Z" } },
      { "serial_number": "A56N01007", "uuid": "E20020626314020619304B2A", "ok": true,
        "tag_status": { "status_id": 3, "name": "Printed", "last_update": "2026-10-15T14:02:51Z" } },
      { "serial_number": "A56N09999", "uuid": "", "ok": false, "error": "no asset matches that serial_number or uuid" }
    ]
  },
  "error": ""
}
400 Bad Request
{ "status": 400, "data": "", "error": "status_id (or status, by name) is required." }
{ "status": 400, "data": "", "error": "Unknown tag status. Use one from tag_statuses in GET /api/system_settings." }
{ "status": 400, "data": "", "error": "tags must be a non-empty array of {uuid, serial_number}." }

GET /api/system_settings

Everything the app's dropdowns need. Fetch at app start. The lists below are shortened. (/api/system_setttings, with three ts as in the original spec, also works.)

GET /api/system_settings
Authorization: Bearer eyJ0eXAi...
200 OK
{
  "status": 200,
  "data": {
    "locations": [
      { "id": 412, "name": "Main Vault",    "locality": "Berryville, AR" },
      { "id": 418, "name": "Shipping Cage", "locality": "Berryville, AR" }
    ],
    "events": [
      { "name": "Manufacturer EOD",    "id": 1, "description": "End of day count at the point of manufacture", "requires_location": false },
      { "name": "Shipping EOD",        "id": 2, "description": "End of day count at shipping",                 "requires_location": false },
      { "name": "Location Assignment", "id": 3, "description": "Assign scanned assets to a location",           "requires_location": true }
    ],
    "tag_statuses": [
      { "name": "Minted",        "id": 1 },
      { "name": "Pending Print", "id": 2 },
      { "name": "Printed",       "id": 3 },
      { "name": "Re-Printed",    "id": 4 }
    ],
    "asset_statuses": [
      { "name": "New",                "id": 1 },
      { "name": "Used",               "id": 2 },
      { "name": "Scrapped/Destroyed", "id": 3 },
      { "name": "Lost or Stolen",     "id": 5 },
      { "name": "In Trial/Evidence",  "id": 6 }
    ],
    "case_statuses": [
      { "name": "OPEN",    "id": 1 },
      { "name": "CLOSED",  "id": 2 },
      { "name": "SHIPPED", "id": 3 }
    ],
    "manufacturers": [
      { "name": "GLOCK",    "id": 662 },
      { "name": "Surefire", "id": 3401 }
    ],
    "models": [
      { "name": "G17",  "id": 1521,  "manufacturer_id": 662 },
      { "name": "G19",  "id": 1530,  "manufacturer_id": 662 },
      { "name": "X300", "id": 12034, "manufacturer_id": 3401 }
    ],
    "types": [
      { "name": "Pistol",   "id": 3 },
      { "name": "Receiver", "id": 1 },
      { "name": "Rifle",    "id": 2 }
    ],
    "calibers": [
      { "name": "9mm",       "id": 77 },
      { "name": "9mm Luger", "id": 333 }
    ],
    "lengths": [
      { "name": "4.0",    "id": 268 },
      { "name": "16.0\"", "id": 6 }
    ],
    "chain": [
      { "model_id": 1521,  "type_id": 3, "caliber_id": 333 },
      { "model_id": 1521,  "type_id": 1, "caliber_id": 333 },
      { "model_id": 1530,  "type_id": 3, "caliber_id": 77 },
      { "model_id": 12034, "type_id": 3, "caliber_id": null }
    ]
  },
  "error": ""
}

Chained dropdowns. Every option is {name, id}. Lengths are merged by their text, so a length's id is the lowest of the ids sharing it. chain links model, type and caliber by id:

manufacturer   all of manufacturers
model          models where manufacturer_id == chosen manufacturer's id
type           types whose id appears as type_id in chain where model_id == chosen model's id
caliber        calibers whose id appears as caliber_id in chain where model_id and type_id match
length         all of lengths (not chained)

In the example, GLOCK offers G17 and G19. G17 offers Pistol and Receiver, and G17 + Pistol offers 9mm Luger. A null caliber_id in the chain means the model comes in that type but has no caliber on record. Offer the type anyway.

POST /api/asset/event

Record an event against scanned assets. Up to 1000 per call. Send event_id (or event_name) from events in system_settings; if both are sent, the id wins. Each asset is identified by serial_number or uuid, with serial tried first. Unidentified assets are reported with ok: false and are not written to the log. One log row per asset, sharing a batch_id.

POST /api/asset/event
Authorization: Bearer eyJ0eXAi...
Content-Type: application/json

{
  "event_id": 3,
  "location_id": 412,
  "tags": [
    { "uuid": "E20020626314020619304B29", "serial_number": "A56N01006" },
    { "uuid": "", "serial_number": "A56N01007" },
    { "uuid": "E200FFFFFFFFFFFFFFFFFFFF", "serial_number": "" }
  ]
}
201 Created
{
  "status": 201,
  "data": {
    "batch_id": "0b7c1e4a-5d2f-4c8e-9a61-3f0e2b7d9c14",
    "event": { "id": 3, "name": "Location Assignment" },
    "recorded": 2,
    "results": [
      { "serial_number": "A56N01006", "uuid": "E20020626314020619304B29", "ok": true },
      { "serial_number": "A56N01007", "uuid": "E20020626314020619304B2A", "ok": true },
      { "serial_number": "", "uuid": "E200FFFFFFFFFFFFFFFFFFFF", "ok": false, "error": "no asset matches that serial_number or uuid" }
    ]
  },
  "error": ""
}
400 Bad Request
{ "status": 400, "data": "", "error": "Unknown event 'Shiping EOD'. Use an event from GET /api/system_settings." }
{ "status": 400, "data": "", "error": "event_id or event_name is required." }
{ "status": 400, "data": "", "error": "tags must be a non-empty array of {uuid, serial_number}." }
{ "status": 400, "data": "", "error": "At most 1000 assets per event." }

POST /api/case/register

A case is a physical box holding both serialized and non-serialized assets. This mints its labels, 1–200 per call. Codes are case- plus six lower-case letters and digits, such as case-7k2m9x, with no i, l, o or u. They are random, not sequential, and unique across every organization. Scanned codes match in any letter case, and older CASE-XMPGQ41MS4-style codes still work. note is optional.

POST /api/case/register
Authorization: Bearer eyJ0eXAi...
Content-Type: application/json

{ "quantity": 2, "note": "Line 3, morning shift" }
201 Created
{
  "status": 201,
  "data": { "case_ids": ["case-7k2m9x", "case-q4r8tz"], "quantity": 2 },
  "error": ""
}

400 Bad Request
{ "status": 400, "data": "", "error": "quantity must be between 1 and 200." }

POST /api/case/assign

Scan assets into a case, up to 500 per call. An asset is in at most one case, so assigning it elsewhere moves it. quantity defaults to 1 and matters for non-serialized stock. It must be a whole number of at least 1: 1.5 or 0 fails that asset with "quantity must be a whole number of at least 1". Partial success works as it does for tags.

POST /api/case/assign
Authorization: Bearer eyJ0eXAi...
Content-Type: application/json

{
  "case_id": "case-7k2m9x",
  "tags": [
    { "serial_number": "A56N01006", "uuid": "E20020626314020619304B29", "quantity": 1 },
    { "serial_number": "A56N01007", "uuid": "" },
    { "serial_number": "NOSUCHSERIAL", "uuid": "" }
  ]
}
200 OK
{
  "status": 200,
  "data": {
    "case_id": "case-7k2m9x",
    "assigned": 2,
    "results": [
      { "serial_number": "A56N01006", "uuid": "E20020626314020619304B29", "ok": true,
        "tag_status": { "status_id": 3, "name": "Printed", "last_update": "2026-10-15T14:02:51Z" } },
      { "serial_number": "A56N01007", "uuid": "", "ok": true,
        "tag_status": { "status_id": 1, "name": "Minted", "last_update": "2026-10-15T13:58:40Z" } },
      { "serial_number": "NOSUCHSERIAL", "uuid": "", "ok": false, "error": "no asset matches that serial_number or uuid" }
    ]
  },
  "error": ""
}
400 Bad Request   (an unknown case is 400 here, not 404)
{ "status": 400, "data": "", "error": "No case with code 'case-zzzzzz'." }
{ "status": 400, "data": "", "error": "case_id is required." }
{ "status": 400, "data": "", "error": "tags must be a non-empty array of {uuid, serial_number}." }
{ "status": 400, "data": "", "error": "At most 500 assets per request." }

POST /api/case/event

Record an event against everything currently in a case. Membership is read at the moment of recording. location_id is accepted as for /api/asset/event.

POST /api/case/event
Authorization: Bearer eyJ0eXAi...
Content-Type: application/json

{ "event_id": 2, "case_id": "case-7k2m9x" }
201 Created
{
  "status": 201,
  "data": {
    "batch_id": "6d2f8a10-3b7e-4f51-8c2d-91e0a4b6f7c3",
    "event": { "id": 2, "name": "Shipping EOD" },
    "recorded": 12,
    "case_id": "case-7k2m9x"
  },
  "error": ""
}

404 Not Found
{ "status": 404, "data": "", "error": "No case with code 'case-zzzzzz'." }

400 Bad Request
{ "status": 400, "data": "", "error": "case_id, and event_id or event_name, are required." }
{ "status": 400, "data": "", "error": "Unknown event 'Shiping EOD'. Use an event from GET /api/system_settings." }

Errors outside the envelope

401 Unauthorized            (no token, or expired: refresh and retry)
{ "error": { "code": "not_authenticated", "detail": "A valid WordPress session cookie or bearer token is required.", "status": 401 } }

403 Forbidden               (signed in, but in no FFL organization)
{ "error": { "code": "no_organization", "detail": "This user is authenticated but not a member of any organization.", "status": 403 } }

Limits: 500 serials, tags, tag-status updates or case-assigns per call; 1000 assets per event; 200 cases per call; 2 MB request body. The rate limit is 30 requests/second per client IP with a burst of 100.

ATF FFL Direct

ATF's National Tracing Center sends firearm trace requests to this API. We search the FFL's A&D records and send back what we hold.

Nothing reaches ATF without a person approving it. A trace request is queued, matched, and left waiting. Approval records a decision and the exact bytes it covers; transmitting is a separate call that refuses if those bytes changed. There is no automatic send, no scheduled job, and no code path from receiving a request to answering one.

Receiving a trace request

ATF authenticates with HTTP Basic using a credential we issue them. Their service is asynchronous: this call only acknowledges receipt, and the answer goes back later over a separate request.

POST /atf/v1/trace-request
Authorization: Basic <credential issued to ATF>
Content-Type: application/json

{
  "requestId": "2VFR67E3-A623-BDFG-9JR3-KHT98HVAR65A",
  "traceId": "T20250000923",
  "tracePriority": "R",
  "serialNumber": "SAA10001",
  "manufacturer": "Diamond Back Arms",
  "firearmType": "P",
  "caliber": "9",
  "model": "DB9",
  "firearmCountryOfOrigin": "US",
  "fflIdentifier": "11111111"
}
200 OK
{"received":true,"requestId":"2VFR67E3-...","status":"AWAITING_APPROVAL"}

The response says nothing about whether we hold a matching record. That answer is not ours to give until a person has approved it.

requestId is the only required field, and it is unique — a redelivery acknowledges without queuing a second copy, so two people cannot approve two different answers to the same trace. fflIdentifier tells us which organization the trace is for; one that is not mapped is kept with status NO_ORG rather than dropped.

Review and approve

These endpoints are for your operators. Each needs a bearer token, a capability, and is scoped to the caller's organization.

EndpointCapabilityDescription
GET /v1/atf/traces atf_trace_view The queue. Defaults to AWAITING_APPROVAL.
GET /v1/atf/traces/{id} atf_trace_view The request, matched records, the exact payload and its SHA-256, the decision, and the full history.
PUT /v1/atf/traces/{id}/candidates atf_trace_approve Choose which matched records go in the response.
POST /v1/atf/traces/{id}/approve atf_trace_approve Records a decision. Does not send.
POST /v1/atf/traces/{id}/reject atf_trace_approve Decline to respond. Nothing is ever sent for that trace.
POST /v1/atf/traces/{id}/send atf_trace_send Transmit to ATF. Refuses without an approval covering these bytes.
GET /v1/atf/audit atf_trace_view Append-only activity log for your organization.

atf_trace_send is deliberately a separate capability from atf_trace_approve, so you can require two different people if you want to.

Approving echoes back the payload hash the client displayed. If another session changed the selection since the page loaded, the approval is refused with 409 payload_changed rather than accepting a decision about bytes nobody saw.

There is a console for all of this at /console.html — sign in with my.apb360.com credentials.

Field mapping

A&D records store human-readable values; ATF validates against coded vocabularies. These are translated automatically against a committed snapshot of ATF's own reference data.

FieldA&DSent
mfgCountryUnited StatesUS
weaponTypeRevolverPR
weaponCaliber.45ACP45
dispositionFFL9-86-013-07-4D-0078898600788
ATF's country codes are not ISO 3166. AU is Austria, not Australia; UK is Ukraine, and the United Kingdom is UD. Values are resolved only against ATF's own list — never guessed — because a plausible-looking wrong code would misattribute a firearm in a federal record.

A value that cannot be resolved is omitted or set to ATF's own "unknown" code rather than invented. weaponModel is sent as recorded and not mapped: ATF's model list runs to 21,000 entries with no reliable correspondence to ours, so a near-match would misdescribe the firearm. ATF flags it and an agent follows up.

ATF replies SUCCESS, or ACCEPTED with an errors array when a field is missing or unrecognised. ACCEPTED means they took the record — it is not a rejection.

Websockets

The websocket channel pushes events from the main system as they happen, so clients do not have to poll.

Browsers cannot set an Authorization header on a websocket handshake. Rather than putting a long-lived token in the URL — where it would land in browser history and server logs — you exchange one for a ticket that is good for 30 seconds and can only be used once.

// 1. Get a ticket over normal authenticated HTTP.
const res = await fetch('/v1/auth/ws-ticket', {
  method:  'POST',
  headers: { Authorization: `Bearer ${accessToken}` },
});
const { ticket, url } = await res.json();

// 2. Spend it immediately. It expires in 30 seconds.
const ws = new WebSocket(`${url}?ticket=${ticket}`);

ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'subscribe', topic: 'org:42' }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  console.log(msg.type, msg.payload);
};

Messages you send

MessageEffect
{"type":"subscribe","topic":"org:42"}Start receiving that topic.
{"type":"unsubscribe","topic":"org:42"}Stop.
{"type":"ping"}Server replies pong.

Messages you receive

MessageMeaning
welcomeSent on connect, with your user_id.
subscribedSubscription accepted.
errorcode is forbidden, bad_json, or unknown_type.
pongReply to your ping.
anything elseAn event. type, topic, payload, at.
Keepalive is automatic. The server pings every 30 seconds and drops connections that stop answering. You do not need your own heartbeat, but you do need to reconnect — get a fresh ticket, do not reuse the old one.

Topics

TopicWho may subscribe
user:<your user id>Only you.
org:<your org id>Members of that organization.
inventory:<your org id>Members of that organization whose token carries read_inventory.

Any other topic, including another user's or another organization's, is refused with {"type":"error","code":"forbidden"}. Permission is checked once at subscribe time, against the ticket you connected with.

Inventory events

Every committed change to a unit count in your organization is announced on inventory:<your org id> as an inventory.moved event: an adjustment, a transfer between locations, an issue to or return from an employee, a package issue, consumption at a range qualification, and so on. One event is one movement in the inventory ledger, so a transfer arrives once, with both of its sides.

{
  "type":  "inventory.moved",
  "topic": "inventory:42",
  "at":    "2026-10-17T18:48:38Z",
  "payload": {
    "asset_id":       456884,
    "movement_id":    131227,
    "reason":         "transfer",
    "total":          6414,
    "location_total": 6403,
    "employee_total": 11,
    "holders": [
      { "type": "location", "id": "2238", "balance": 3997 },
      { "type": "location", "id": "2505", "balance": 1662 }
    ]
  }
}
FieldMeaning
reasonprocure, adjust, physical_count, transfer, issue, return, package_issue, consume, qualification, training, destroy, theft, correction, or unrecorded_change (a count changed by a path that does not yet report its reason).
totalThe asset's units across every holder after the movement, split into location_total and employee_total. Negative counts are real and included: consumption recorded at the range can run ahead of stock.
holdersThe holders this movement changed, each with its count afterwards. Holders it did not touch are not listed.
movement_idIncreases with every movement. Use it to ignore an event you have already applied.
Events say what changed, not everything that is. Treat an event as a cue to refresh the asset it names. If you were disconnected, you may have missed some: refetch what you display when you reconnect. Only the system publishes inventory events — /v1/events/publish posts to your organization topic and cannot write to inventory:.

Publishing events

POST /v1/events/publish broadcasts to every client subscribed to your organization's topic. The topic is derived from your account — there is no way to publish into someone else's.

curl -sS -X POST https://api-next.apb360.com/v1/events/publish \
     -H "Authorization: Bearer $ACCESS_TOKEN" \
     -H 'Content-Type: application/json' \
     -d '{
           "type": "asset.scanned",
           "payload": { "serial_number": "ABC123", "location_id": 7 }
         }'

# 202 Accepted
# { "accepted": true, "topic": "org:42", "type": "asset.scanned", "delivered": 3 }

type must be 2–49 characters of a-z 0-9 _ . — use a dotted name like asset.scanned. Subscribers receive the payload with an added _actor object naming who published it.

202, not 200. The event was accepted and fanned out to whoever happened to be connected. delivered counts those clients. It is not a delivery guarantee, and null means the realtime service could not be reached at all — the event is simply lost, not queued.

Live console

Paste an access token to try the API and the websocket against this deployment. Nothing is stored; the token stays in this tab.

disconnected
Ready. Paste a token to begin.