Skip to content

Notifications and subscriptions

ONE Record pushes: a holder tells subscribers when an object is created or updated, when an event is posted on it, when access is granted, and when an action request changes status. The SDK decides who must be told and what; the host does the sending.

Receiving

POST /notifications accepts an api:Notification from any authenticated party, validates it, raises Server\Event\NotificationReceived with the parsed Api\Notification and the sender, and answers 204. Nothing is stored: what to do with a notification (fetch the object, update a status) is the host's business, done in the event listener.

Sending: the outbox

Every notification the server wants to send is queued in the NotificationOutbox as an OutboundNotification: the recipient agent URI, the Api\Notification, and when it was created. The host drains the outbox (a queue job, a cron) and delivers each with the SDK client to the recipient's /notifications endpoint. The server never opens a connection itself, so egress stays under the host's control and a slow partner never slows a request.

foreach ($outbox->drain() as $outbound) {
    // The client is bound to one partner server; resolve the recipient's endpoint
    // from your partner registry, or fall back to the spec's derivation.
    $endpoint = $partners->endpointOf($outbound->recipient) ?? $outbound->suggestedEndpoint();
    $client = $clients->for($endpoint);   // one OneRecordClient per partner, cached
    $client->sendNotification($outbound->notification, idempotencyKey: $outbound->id);
}

Every OutboundNotification carries an id. Keep it with your delivery row and never send the same id twice as a new notification. Partners may use the id to deduplicate; sending it as an Idempotency-Key header is a reasonable convention until the spec names one.

What an outbox worker looks like

Two hosts built this independently and converged on the same shape, so it is written down here as the specification to copy rather than another host's code.

Row. One per OutboundNotification: the SDK id (unique), recipient, endpoint (resolved at enqueue time, may be null), event type, logistics object, the notification document as JSON-LD, created-at, attempts, next-attempt-at, delivered-at, failed-at, last error. The row is written inside the unit of work with the operation it announces (see the SPI guide); hand it to the queue from the connection's after-commit hook, never from enqueue() itself.

Claim. A worker takes a row by one conditional update: attempts goes up by one and next-attempt-at moves into the future (the lease), only if the row is still undelivered, unfailed and due. Exactly one worker wins. Record the outcome with the same attempt number the claim produced, so a worker that outlived its lease cannot overwrite a later attempt's result.

Outcome. Deliver with the SDK client and classify a failure with Client\DeliveryVerdict::of($throwable): Retry for transport failures and for 5xx, 408 and 429 (set next-attempt-at with backoff, give up as failed after N attempts), Reject for every other HTTP answer and for sending-side defects (set failed-at at once; an operator may requeue after fixing the cause). Success sets delivered-at. Delivery is at least once however carefully this is done; the recipient deduplicates on the id.

Who gets what

Server\Notification\Fanout applies the spec's rules:

Trigger Event type Recipients
Object created (DataHolder::create, publish) LOGISTICS_OBJECT_CREATED Subscribers to the object's type
Change request accepted and applied LOGISTICS_OBJECT_UPDATED with api:hasChangedProperty Subscribers to the object or its type
Logistics event posted LOGISTICS_EVENT_RECEIVED with api:hasLogisticsEvent Subscribers to the object or its type
Access delegation accepted LOGISTICS_OBJECT_ACCESS_GRANTED Each delegate
Action request status change, when the requestor asked *_REQUEST_ACCEPTED / _REJECTED / _REVOKED / _FAILED / _ACKNOWLEDGED The requestor
DataHolder::announce LOGISTICS_OBJECT_AVAILABLE The named partner

A subscription lists the event types it wants (api:includeSubscriptionEventType); others are not sent. With api:sendLogisticsObjectBody: true the object's current JSON-LD is embedded in api:hasLogisticsObject; otherwise only the URI is sent. Expired subscriptions (api:expiresAt) are ignored.

The changed properties of an update are the object's own properties, with an edit inside an embedded node counted for the property it hangs off (changing a weight's numericalValue reports cargo:grossWeight).

Subscribing to this server

A partner sends POST /subscriptions with an api:Subscription: its own agent URI as api:hasSubscriber, a topic type (LOGISTICS_OBJECT_IDENTIFIER for one object, LOGISTICS_OBJECT_TYPE for every object of a class) and the topic. The topic must be an object of this server (a hidden one looks like a missing one) or a logistics-object class of the ontology. The result is a pending SubscriptionRequest (201 with its Location) that the holder accepts or rejects. Only accepted subscriptions receive notifications; a subscriber revokes with DELETE on the request.

Being asked to subscribe

The spec also lets a publisher ask you what you want: GET /subscriptions?topicType=…&topic=…. The answer comes from SubscriptionStore::offered(): the subscriptions the host registered as its interests (InMemorySubscriptionStore::offer()). One offer is answered as an api:Subscription; none or several as an api:Collection (spec question 17).

Subscriptions the holder sets up

When a partner has answered such a question to you, record the subscription with DataHolder::subscribe(Subscription): it becomes an accepted SubscriptionRequest, so notifications reference it and the partner can revoke it like any other.