ObjectStackObjectStack

Push Relay schema — API Protocol reference

Push relay wire protocol — what a server sends the hosted push relay, and what the relay answers.

Push relay wire protocol — what a server sends the hosted push relay, and what the relay answers.

Why there is a relay. The store-published ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, and those credentials never leave ObjectStack. So a server whose push transport is the relay (PushRelayTransportConfigSchema, system/push-config.zod.ts, the default) does not call APNs or FCM itself: it POSTs one PushRelayRequestSchema to the relay endpoint, and the relay forwards each delivery to its vendor. One schema, pinned by both ends: the push channel that sends (the server) and the relay that receives it (in objectstack-ai/cloud).

Who is asking. The relay authenticates the server by its cloud binding credential (cloud ADR-0008, the oscc_… bearer the server received when it was bound), sent as the request's bearer token. The body carries no credential. A bad or revoked binding answers 401 (PushRelayErrorResponseSchema).

ID-only payload. The relay is a third party to the notification, so it never sees its content. A delivery's payload carries the push delivery data (PushDeliveryDataSchema: the notification id, its same-server route and the sending server's origin) and one generic line of visible text, and nothing else. There is no title or body: the shell reads the notification from the server after the tap, by its id. To the relay the payload is opaque: it puts it in the vendor message unchanged.

Declared ahead of its producer. No push channel delivers yet, so nothing sends this request today; each schema here carries a [PLANNED — not yet served] marker in its .describe() until the push channel ships.

Source: packages/spec/src/api/push-relay.zod.ts

TypeScript Usage

import { PushRelayDeliverySchema, PushRelayErrorSchema, PushRelayErrorResponseSchema, PushRelayPayloadSchema, PushRelayPrioritySchema, PushRelayRequestSchema, PushRelayResponseSchema, PushRelayResultSchema, PushRelayResultStatusSchema } from '@objectstack/spec/api';
import type { PushRelayDelivery, PushRelayError, PushRelayErrorResponse, PushRelayPayload, PushRelayPriority, PushRelayRequest, PushRelayResponse, PushRelayResult, PushRelayResultStatus } from '@objectstack/spec/api';

// Validate data
const result = PushRelayDeliverySchema.parse(data);

PushRelayDelivery

[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority

Properties

PropertyTypeRequiredDescription
tokenstring✅The vendor device token, as the device registered it
vendorEnum<'apns' | 'fcm'>✅The vendor the token belongs to: apns or fcm
payload{ data: object; text: string }✅The ID-only payload
priorityEnum<'high' | 'normal'>✅Vendor delivery priority

Nested Shape: PushRelayDelivery.payload

PropertyTypeRequiredDescription
data{ notificationId: string; route?: string; origin?: string }✅The push delivery data: the notification id, its same-server route and the origin of the sending server. The relay puts it in the vendor message unchanged
textstring✅One generic visible line the device shows (for example "You have a new notification"), in the device locale. Never the notification title or body

PushRelayError

[PLANNED — not yet served] A push relay error: UNAUTHENTICATED (401, bad or revoked binding), RATE_LIMIT_EXCEEDED or QUOTA_EXCEEDED (429), each with its next step in helpText

Union Options

This schema accepts one of the following structures:

Option 1

Properties

PropertyTypeRequiredDescription
code'UNAUTHENTICATED'✅The server binding credential was not accepted
messagestring✅Human-readable error message
userMessagestringoptionalProducer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution.
refusaltrueoptionalProducer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose message is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld.
categoryEnum<'validation' | 'authentication' | 'authorization' | 'not_found' | 'conflict' | 'rate_limit' | 'server' | 'external' | 'maintenance'>optionalError category
httpStatus401✅HTTP status code
retryablebooleanoptional (default: false)Whether the request can be retried
retryStrategyEnum<'no_retry' | 'retry_immediate' | 'retry_backoff' | 'retry_after'>optionalRecommended retry strategy
retryAfterSecondsnumberoptionalSeconds to wait before retrying
retryAfterneveroptional[REMOVED] EnhancedApiError.retryAfter was renamed to retryAfterSeconds in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to retryAfterSeconds; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP Retry-After response header — that header keeps its RFC 9110 name and is untouched.
details{ reason: Enum<'binding_invalid' | 'binding_revoked'> }✅Why the binding was refused
fields{ field: string; code: Enum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>; message: string; label?: string; … }[]optionalOne entry per offending value
fieldErrorsneveroptional[REMOVED] EnhancedApiError.fieldErrors was renamed to fields in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted fields; fieldErrors was declared and never emitted, so a reader keying on it was reading a field no server sent.
timestampstringoptionalWhen the error occurred
requestIdstringoptionalRequest ID for tracking
traceIdstringoptionalDistributed trace ID
documentationstringoptionalURL to error documentation
helpTextstring✅The next step: bind the server to the cloud again

Nested Shape: PushRelayError[code='UNAUTHENTICATED'].details

PropertyTypeRequiredDescription
reasonEnum<'binding_invalid' | 'binding_revoked'>✅binding_invalid: the relay does not recognise the credential; binding_revoked: it was valid and has been revoked

Nested Shape: PushRelayError[code='UNAUTHENTICATED'].fields[number]

PropertyTypeRequiredDescription
fieldstring✅Field path (supports dot notation)
codeEnum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>✅Which constraint the value violated (field-level catalog, ADR-0114)
messagestring✅Human-readable error message, rendered in the caller’s locale
labelstringoptionalField display label in the caller’s locale
valueanyoptionalThe invalid value that was provided
constraintRecord<string, any>optionalThe constraint that was violated, as discrete values (e.g. { maxLength: 512, actual: 3000 })

Option 2

Properties

PropertyTypeRequiredDescription
code'RATE_LIMIT_EXCEEDED'✅The server sent the relay too many requests
messagestring✅Human-readable error message
userMessagestringoptionalProducer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution.
refusaltrueoptionalProducer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose message is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld.
categoryEnum<'validation' | 'authentication' | 'authorization' | 'not_found' | 'conflict' | 'rate_limit' | 'server' | 'external' | 'maintenance'>optionalError category
httpStatus429✅HTTP status code
retryablebooleanoptional (default: false)Whether the request can be retried
retryStrategyEnum<'no_retry' | 'retry_immediate' | 'retry_backoff' | 'retry_after'>optionalRecommended retry strategy
retryAfterSecondsinteger✅Seconds to wait before sending again
retryAfterneveroptional[REMOVED] EnhancedApiError.retryAfter was renamed to retryAfterSeconds in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to retryAfterSeconds; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP Retry-After response header — that header keeps its RFC 9110 name and is untouched.
detailsanyoptionalAdditional error context
fields{ field: string; code: Enum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>; message: string; label?: string; … }[]optionalOne entry per offending value
fieldErrorsneveroptional[REMOVED] EnhancedApiError.fieldErrors was renamed to fields in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted fields; fieldErrors was declared and never emitted, so a reader keying on it was reading a field no server sent.
timestampstringoptionalWhen the error occurred
requestIdstringoptionalRequest ID for tracking
traceIdstringoptionalDistributed trace ID
documentationstringoptionalURL to error documentation
helpTextstring✅The next step: send again after retryAfterSeconds

Nested Shape: PushRelayError[code='RATE_LIMIT_EXCEEDED'].fields[number]

PropertyTypeRequiredDescription
fieldstring✅Field path (supports dot notation)
codeEnum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>✅Which constraint the value violated (field-level catalog, ADR-0114)
messagestring✅Human-readable error message, rendered in the caller’s locale
labelstringoptionalField display label in the caller’s locale
valueanyoptionalThe invalid value that was provided
constraintRecord<string, any>optionalThe constraint that was violated, as discrete values (e.g. { maxLength: 512, actual: 3000 })

Option 3

Properties

PropertyTypeRequiredDescription
code'QUOTA_EXCEEDED'✅The push quota of the server plan is used up
messagestring✅Human-readable error message
userMessagestringoptionalProducer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution.
refusaltrueoptionalProducer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose message is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld.
categoryEnum<'validation' | 'authentication' | 'authorization' | 'not_found' | 'conflict' | 'rate_limit' | 'server' | 'external' | 'maintenance'>optionalError category
httpStatus429✅HTTP status code
retryablebooleanoptional (default: false)Whether the request can be retried
retryStrategyEnum<'no_retry' | 'retry_immediate' | 'retry_backoff' | 'retry_after'>optionalRecommended retry strategy
retryAfterSecondsintegeroptionalSeconds until the quota resets, when known
retryAfterneveroptional[REMOVED] EnhancedApiError.retryAfter was renamed to retryAfterSeconds in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to retryAfterSeconds; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP Retry-After response header — that header keeps its RFC 9110 name and is untouched.
detailsanyoptionalAdditional error context
fields{ field: string; code: Enum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>; message: string; label?: string; … }[]optionalOne entry per offending value
fieldErrorsneveroptional[REMOVED] EnhancedApiError.fieldErrors was renamed to fields in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted fields; fieldErrors was declared and never emitted, so a reader keying on it was reading a field no server sent.
timestampstringoptionalWhen the error occurred
requestIdstringoptionalRequest ID for tracking
traceIdstringoptionalDistributed trace ID
documentationstringoptionalURL to error documentation
helpTextstring✅The next step: raise the plan, or wait for the quota to reset

Nested Shape: PushRelayError[code='QUOTA_EXCEEDED'].fields[number]

PropertyTypeRequiredDescription
fieldstring✅Field path (supports dot notation)
codeEnum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>✅Which constraint the value violated (field-level catalog, ADR-0114)
messagestring✅Human-readable error message, rendered in the caller’s locale
labelstringoptionalField display label in the caller’s locale
valueanyoptionalThe invalid value that was provided
constraintRecord<string, any>optionalThe constraint that was violated, as discrete values (e.g. { maxLength: 512, actual: 3000 })


PushRelayErrorResponse

[PLANNED — not yet served] The push relay error response: the house error envelope, for 401 (bad or revoked binding) and 429 (rate limit or quota)

Properties

PropertyTypeRequiredDescription
successfalse✅Always false for error responses
error{ code: 'UNAUTHENTICATED'; message: string; userMessage?: string; refusal?: true; … } | { code: 'RATE_LIMIT_EXCEEDED'; message: string; userMessage?: string; refusal?: true; … } | { code: 'QUOTA_EXCEEDED'; message: string; userMessage?: string; refusal?: true; … }✅Error details
meta{ timestamp?: string; requestId?: string; traceId?: string }optionalResponse metadata

Nested Shape: PushRelayErrorResponse.error[code='UNAUTHENTICATED']

PropertyTypeRequiredDescription
code'UNAUTHENTICATED'✅The server binding credential was not accepted
messagestring✅Human-readable error message
userMessagestringoptionalProducer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution.
refusaltrueoptionalProducer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose message is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld.
categoryEnum<'validation' | 'authentication' | 'authorization' | 'not_found' | 'conflict' | …>optionalError category
httpStatus401✅HTTP status code
retryablebooleanoptional (default: false)Whether the request can be retried
retryStrategyEnum<'no_retry' | 'retry_immediate' | 'retry_backoff' | 'retry_after'>optionalRecommended retry strategy
retryAfterSecondsnumberoptionalSeconds to wait before retrying
retryAfterneveroptional[REMOVED] EnhancedApiError.retryAfter was renamed to retryAfterSeconds in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to retryAfterSeconds; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP Retry-After response header — that header keeps its RFC 9110 name and is untouched.
details{ reason: Enum<'binding_invalid' | 'binding_revoked'> }✅Why the binding was refused
fields{ field: string; code: Enum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>; message: string; label?: string; … }[]optionalOne entry per offending value
fieldErrorsneveroptional[REMOVED] EnhancedApiError.fieldErrors was renamed to fields in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted fields; fieldErrors was declared and never emitted, so a reader keying on it was reading a field no server sent.
timestampstringoptionalWhen the error occurred
requestIdstringoptionalRequest ID for tracking
traceIdstringoptionalDistributed trace ID
documentationstringoptionalURL to error documentation
helpTextstring✅The next step: bind the server to the cloud again

Nested Shape: PushRelayErrorResponse.error[code='RATE_LIMIT_EXCEEDED']

PropertyTypeRequiredDescription
code'RATE_LIMIT_EXCEEDED'✅The server sent the relay too many requests
messagestring✅Human-readable error message
userMessagestringoptionalProducer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution.
refusaltrueoptionalProducer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose message is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld.
categoryEnum<'validation' | 'authentication' | 'authorization' | 'not_found' | 'conflict' | …>optionalError category
httpStatus429✅HTTP status code
retryablebooleanoptional (default: false)Whether the request can be retried
retryStrategyEnum<'no_retry' | 'retry_immediate' | 'retry_backoff' | 'retry_after'>optionalRecommended retry strategy
retryAfterSecondsinteger✅Seconds to wait before sending again
retryAfterneveroptional[REMOVED] EnhancedApiError.retryAfter was renamed to retryAfterSeconds in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to retryAfterSeconds; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP Retry-After response header — that header keeps its RFC 9110 name and is untouched.
detailsanyoptionalAdditional error context
fields{ field: string; code: Enum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>; message: string; label?: string; … }[]optionalOne entry per offending value
fieldErrorsneveroptional[REMOVED] EnhancedApiError.fieldErrors was renamed to fields in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted fields; fieldErrors was declared and never emitted, so a reader keying on it was reading a field no server sent.
timestampstringoptionalWhen the error occurred
requestIdstringoptionalRequest ID for tracking
traceIdstringoptionalDistributed trace ID
documentationstringoptionalURL to error documentation
helpTextstring✅The next step: send again after retryAfterSeconds

Nested Shape: PushRelayErrorResponse.error[code='QUOTA_EXCEEDED']

PropertyTypeRequiredDescription
code'QUOTA_EXCEEDED'✅The push quota of the server plan is used up
messagestring✅Human-readable error message
userMessagestringoptionalProducer-marked user-facing refusal text, verbatim — see ApiErrorSchema.userMessage. Present only when the producer opted in at throw time; unmarked errors keep the generic consumer substitution.
refusaltrueoptionalProducer-declared deliberate refusal — see ApiErrorSchema.refusal. Present only when the producer declared it at throw time; a declared 5xx without it is a fault whose message is withheld. Until the withhold arms read the declaration, a declared refusal is still withheld.
categoryEnum<'validation' | 'authentication' | 'authorization' | 'not_found' | 'conflict' | …>optionalError category
httpStatus429✅HTTP status code
retryablebooleanoptional (default: false)Whether the request can be retried
retryStrategyEnum<'no_retry' | 'retry_immediate' | 'retry_backoff' | 'retry_after'>optionalRecommended retry strategy
retryAfterSecondsintegeroptionalSeconds until the quota resets, when known
retryAfterneveroptional[REMOVED] EnhancedApiError.retryAfter was renamed to retryAfterSeconds in @objectstack/spec 17 — the unit of a duration-shaped number lives in the key name, not only in the describe prose. Rename the key to retryAfterSeconds; the value (seconds) is unchanged. This is the ADR-0112 error envelope, not the HTTP Retry-After response header — that header keeps its RFC 9110 name and is untouched.
detailsanyoptionalAdditional error context
fields{ field: string; code: Enum<'required' | 'invalid_type' | 'invalid_shape' | 'unknown_field' | …>; message: string; label?: string; … }[]optionalOne entry per offending value
fieldErrorsneveroptional[REMOVED] EnhancedApiError.fieldErrors was renamed to fields in @objectstack/spec 17 (ADR-0114 D4) — the array is unchanged, only the property name. Every producer already emitted fields; fieldErrors was declared and never emitted, so a reader keying on it was reading a field no server sent.
timestampstringoptionalWhen the error occurred
requestIdstringoptionalRequest ID for tracking
traceIdstringoptionalDistributed trace ID
documentationstringoptionalURL to error documentation
helpTextstring✅The next step: raise the plan, or wait for the quota to reset

PushRelayPayload

[PLANNED — not yet served] The ID-only payload of one relay delivery: the push delivery data and one generic visible line, with no title or body. Opaque to the relay

Properties

PropertyTypeRequiredDescription
data{ notificationId: string; route?: string; origin?: string }✅The push delivery data: the notification id, its same-server route and the origin of the sending server. The relay puts it in the vendor message unchanged
textstring✅One generic visible line the device shows (for example "You have a new notification"), in the device locale. Never the notification title or body

Nested Shape: PushRelayPayload.data

PropertyTypeRequiredDescription
notificationIdstring✅Notification id — the one the inbox lists and markRead takes
routestringoptionalThe notification actionUrl, the value the inbox serves, when it is a same-server relative path; absent otherwise. The shell resolves it on tap as the inbox bell and Home do, never verbatim
originstringoptionalThe origin of the server that sent the push (for example https://crm.example.com): the push channel copies it from the device row, which holds the origin the Console was loaded from when it registered the device. A mobile shell with several saved servers opens the tap on the saved server with this origin, and resolves route there. Absent when the device was registered without one

PushRelayPriority

Vendor delivery priority: high delivers at once and wakes the device (APNs 10, FCM high); normal may wait for the device power state (APNs 5, FCM normal)

Allowed Values

  • high
  • normal

PushRelayRequest

[PLANNED — not yet served] The request a server sends the hosted push relay: one delivery per device token. Authenticated by the server cloud binding credential as its bearer token

Properties

PropertyTypeRequiredDescription
deliveries{ token: string; vendor: Enum<'apns' | 'fcm'>; payload: object; priority: Enum<'high' | 'normal'> }[]✅The deliveries, one per device token. The response answers each one, in the same order

Nested Shape: PushRelayRequest.deliveries[number]

[PLANNED — not yet served] One relay delivery: a device token, its vendor, the ID-only payload and the delivery priority

PropertyTypeRequiredDescription
tokenstring✅The vendor device token, as the device registered it
vendorEnum<'apns' | 'fcm'>✅The vendor the token belongs to: apns or fcm
payload{ data: object; text: string }✅The ID-only payload
priorityEnum<'high' | 'normal'>✅Vendor delivery priority

PushRelayResponse

[PLANNED — not yet served] The push relay answer: one result per delivery token, ok, invalid or retry

Properties

PropertyTypeRequiredDescription
results{ token: string; status: Enum<'ok' | 'invalid' | 'retry'> }[]✅One result per delivery, in the order of the request deliveries

Nested Shape: PushRelayResponse.results[number]

[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome

PropertyTypeRequiredDescription
tokenstring✅The device token of the delivery this result answers
statusEnum<'ok' | 'invalid' | 'retry'>✅What happened to the delivery

PushRelayResult

[PLANNED — not yet served] The relay answer for one delivery: its device token and its outcome

Properties

PropertyTypeRequiredDescription
tokenstring✅The device token of the delivery this result answers
statusEnum<'ok' | 'invalid' | 'retry'>✅What happened to the delivery

PushRelayResultStatus

Outcome of one delivery: ok (the vendor accepted it), invalid (the vendor reports the token dead; stop delivering to it) or retry (a transient failure; the delivery may be sent again later)

Allowed Values

  • ok
  • invalid
  • retry

On this page