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 URL | https://api-next.apb360.com |
|---|---|
| Websocket | wss://api-next.apb360.com/ws |
| Content type | application/json for every request and response |
| Auth | WordPress session cookie, or a bearer token issued by this API |
/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.
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
| Type | Lifetime | Use |
|---|---|---|
access | 15 minutes | Normal API calls. Carries your capabilities. |
refresh | 30 days | Exchange for a new access token. Carries no permissions on its own. |
ws_ticket | 30 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.
org_id parameter to pass, and
supplying one has no effect.
Endpoints
| Endpoint | Auth | Description |
|---|---|---|
GET /health |
none | Liveness. Does not touch the database. |
GET /health/ready |
none | Readiness. Verifies the database is reachable. |
POST /v1/auth/token |
cookie | Mint an access + refresh pair. |
POST /v1/auth/refresh |
refresh token | New access token; permissions re-read. |
POST /v1/auth/ws-ticket |
any | 30-second ticket for one websocket connection. |
GET /v1/session |
any | Your 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
}
}
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_type, invalid_payload | Malformed request body. |
| 401 | not_authenticated | No valid cookie or token. Refresh, then retry. |
| 401 | invalid_refresh_token | Refresh token expired or revoked. Re-authenticate. |
| 403 | missing_capability | Authenticated, but not permitted. |
| 403 | no_organization | Your account is not a member of any organization. |
| 429 | — | Rate limited. Back off and retry. |
| 503 | database_unavailable | Readiness 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
| Path | Sustained | Burst |
|---|---|---|
/v1/auth/* | 2 req/s | 5 |
/api/* (mobile) | 30 req/s | 100 |
| everything else | 30 req/s | 50 |
/ws | 20 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.
| Endpoint | Description |
|---|---|
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:
| Parameter | Matches |
|---|---|
identifier | Whatever was scanned or typed: tried as a serial number, a barcode and a UUID / RFID. matched_on says which matched. |
serial_number | Every serial record with that number in your FFL. |
barcode | The asset's barcode, where one is configured. |
uuid / rfid | The tag's UUID (the two names are the same parameter). A UUID belongs to one serial number; see 409 below. |
asset_id | The 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.
- Manufacturers and calibers are the organization's own. Types and lengths are the organization's plus the shared ones. These match the portal's own dropdowns.
- Duplicate names are merged so each appears once. Blank names, disabled values, and links whose manufacturer disagrees with the model's are left out.
POST /api/assets/registertakes names. Send the chosen option'sname, not itsid.- Events have an
idand can be sent by id or by name. - Statuses.
tag_statusesare the label statuses forPOST /api/tags/status.asset_statusesname an asset'sstatus/status_idon decode (shared with the portal; id 4 is unused).case_statusesname a case'sstatus/status_id. There are no endpoints to change asset or case status yet. requires_locationmeans the event needs alocation_id. The server does not enforce this, so the app must ask for one.
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.
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.
| Endpoint | Capability | Description |
|---|---|---|
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.
| Field | A&D | Sent |
|---|---|---|
mfgCountry | United States | US |
weaponType | Revolver | PR |
weaponCaliber | .45ACP | 45 |
dispositionFFL | 9-86-013-07-4D-00788 | 98600788 |
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
| Message | Effect |
|---|---|
{"type":"subscribe","topic":"org:42"} | Start receiving that topic. |
{"type":"unsubscribe","topic":"org:42"} | Stop. |
{"type":"ping"} | Server replies pong. |
Messages you receive
| Message | Meaning |
|---|---|
welcome | Sent on connect, with your user_id. |
subscribed | Subscription accepted. |
error | code is forbidden, bad_json, or unknown_type. |
pong | Reply to your ping. |
| anything else | An event. type, topic, payload, at. |
Topics
| Topic | Who 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 }
]
}
}
| Field | Meaning |
|---|---|
reason | procure, 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). |
total | The 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. |
holders | The holders this movement changed, each with its count afterwards. Holders it did not touch are not listed. |
movement_id | Increases with every movement. Use it to ignore an event you have already applied. |
/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.