Skip to content
Registry StackDocsv0.39.0

Registry Messaging API

View as Markdown

Registry Messaging serves one HTTP surface for submitting a message, reading and cancelling it, previewing a template, and taking delivery callbacks from providers. Every caller route presents a bearer access token, and the access profile that token resolves to decides what the caller may do. The contract is unreleased and carries no frozen compatibility promise.

Open the Registry Messaging API operations for every route, parameter, schema, and reachable problem code. This page holds the authentication, idempotency, status, and callback rules those operations assume.

Every caller request presents Authorization: Bearer <token> holding an RFC 9068 access token, typ: at+jwt, issued by the one issuer the deployment names, for its audience, to a client the deployment admits. The token’s scopes are read from registry_scopes unless the deployment names another claim. Liveness and readiness take no token, and provider callbacks take none either: a callback verifier authenticates them instead.

POST /v1/messages requires an Idempotency-Key header of 1 to 128 visible ASCII characters, scoped to the calling principal. The same key with the same request answers the stored receipt again, and the same key with a different request is refused with idempotency.key-reused. A key whose stored receipt is older than the deployment’s retention.submissionReceiptDays is refused with idempotency.expired, because the first answer is no longer held. A key whose message has since been deleted by retention.recordDays answers the same idempotency.expired: the row stays under the caller’s keyed pseudonym with its message, request hash, and receipt nulled, so a repeat is refused rather than accepted as a new message to the same recipient.

A refusal answers application/problem+json with type, title, status, detail, code, and traceId. The type is the code with each dot replaced by a slash under https://id.registrystack.org/problems/registry-messaging/, and detail is the fixed sentence that belongs to the code. No field repeats a submitted value. Every response, refusals included, carries Cache-Control: no-store and a traceparent header.

A token resolves to exactly one access profile in the deployed package, by the client it was issued to, the scopes it carries, and, when the profile names one, the actor kind in registry_actor_kind. A token that resolves to no profile is refused with authentication.refused. Messaging inherits no authorization from a calling product: being allowed to act on a record elsewhere does not make a caller allowed to send.

RoleMayRefused with
senderSubmit through the sender profiles and templates its profile lists, submit direct content only when the profile sets allowDirectContent, preview a listed template, and read and cancel the messages its own principal submittedprofile.not-authorized for an unlisted sender profile or template, or for direct content the profile does not allow
operatorRead and cancel every messageoperation.not-authorized for a submission or a preview

A message is visible to the principal that submitted it, named by the token’s issuer and subject, and to every operator. Any other caller reading or cancelling it receives 404 message.not-visible, the same answer a message that does not exist receives.

Each access profile limits its callers’ submissions. requestsPerMinute and burst set a request rate kept per caller, by token issuer and subject, and dailyLimit caps the messages the whole profile accepts in any 24 hours. A submission past either one is refused with 429 and a Retry-After header in seconds:

CodeRefused whenRetry-After
rate-limit.exceededThe caller spent its burstWhen the caller’s rate admits a submission again
quota.exceededThe profile accepted dailyLimit messages in the last 24 hoursWhen the oldest counted message leaves the 24-hour window, at most 86400

The rate is charged after the role check and before the body is read. A replayed submission is charged to the rate but not to the daily limit. Each runtime process keeps its own rate, so a restart refills it; the daily limit is counted in the database and holds across restarts and replicas. A runtime that cannot decide a limit refuses with 503 service.unavailable. With a burst of 10, the eleventh rapid submission from one caller was answered 429 with:

content-type: application/problem+json
retry-after: 1
{"type":"https://id.registrystack.org/problems/registry-messaging/rate-limit/exceeded","title":"Request rate exceeded","status":429,"detail":"The request rate accepted from this caller is exceeded. Try again after the time in Retry-After.","code":"rate-limit.exceeded","traceId":"…"}

A submission is one closed JSON object in camelCase. It names a senderProfile, a recipient in to, and either a template with its locale and data, or direct content with a text and, for email, a subject. to is {"email": ...} for an email sender profile or {"phone": ...} in E.164 form for an SMS one. notBefore, expiresAt, and a correlationId of at most 128 bytes with no control characters are optional. A body that breaks these rules is refused with request.unprocessable.

Messaging renders the message when it accepts it, from the package the deployment serves, and answers 202 with the message id, its status, and links to read and cancel it. Template data is checked against the template version’s JSON Schema before anything renders. A message whose expiresAt passes before it is sent is never sent.

GET /v1/messages/{message_id} answers two facts separately, and one status derived from them:

  • dispatch is what the worker did with the message: queued, sending, submitted, failed, unknown, cancelled, or expired. submitted means the provider accepted the message, never that it was delivered.
  • report is what the provider’s delivery receipts said: none, sent, delivered, or undelivered, and reportedAt records when. A report only moves forward, and delivered and undelivered are final. unavailable means the message’s provider records no receipts, so submitted is the last status the message reaches.
  • status is the dispatch state, except that a submitted message reported delivered is delivered, and one reported undelivered is failed.

unknown means a send may have reached the provider and nobody can tell whether it did. The message stays there until an operator settles it; see Deploy Registry Messaging.

The answer never returns the recipient’s contact: to carries the channel with the value redacted. It returns no rendered part and no template data. attempts lists each attempt by its generation and attempt number, its outcome (in-progress, accepted, transient, permanent, maybe-sent, or interrupted), and whether the provider returned a reference, never the reference itself.

POST /v1/messages/{message_id}/cancel cancels a message that is still queued, including one waiting for a retry. Once dispatch started it answers 409 message.dispatch-started, and once the message reached a final state it answers 409 message.terminal.

A provider that reports delivery calls POST /v1/provider-callbacks/{provider_id} when the runtime configuration gives it an hmac-sha1-url-form or hmac-sha256-body callback verifier, or POST /v1/provider-callbacks/{provider_id}/{token} when it gives it a path-token verifier. These routes take no bearer token: the verifier authenticates each callback, and the provider package’s receipt script reads it.

Before a callback is verified, it is charged to a token bucket, so an anonymous flood cannot spend verification, receipt-script, and store work without bound. Each provider with a verifier has its own bucket, keyed by its configured id, fixed at 6000 a minute with a burst of 600; every path naming no such provider shares one more bucket of the same size. A callback past its bucket answers 429 rate-limit.exceeded with Retry-After and is counted in messaging_limit_refusals_total{limit="callback"} rather than by outcome, so the refusal, like an unverified callback, does not reveal which providers receive callbacks. A limiter that cannot decide answers 503 service.unavailable.

A verified callback answers 204 whether or not its reference names a message, and a receipt never moves a report backwards. An unknown provider, a provider that receives no callbacks, a signature or token that does not verify, and a callback on the route its verifier does not use all answer 403 callback.unverified, so the route does not reveal which providers receive callbacks. A callback the receipt script cannot read answers 422 callback.unreadable, and one the runtime cannot record answers 503 service.unavailable so the provider retries.

Every Messaging problem response carries one code from this closed catalogue, answered with the HTTP status listed beside it. request.not-found answers a path the contract does not declare.

CodeStatusWhen
authentication.refused401The bearer credential is missing, invalid, or expired. Sign in again.
callback.unreadable422The callback was verified, but the provider's receipt script could not read a delivery report from it.
callback.unverified403The callback did not verify for a provider this deployment accepts callbacks from.
content.invalid422The content does not have the parts the channel requires, or the template's channel differs from the sender profile's.
content.too-large422A content part exceeds the size the channel accepts. Shorten the content or the data.
content.too-many-segments422The SMS text needs more segments than the sender profile allows. Shorten the text or the data.
idempotency.expired410The stored response for this idempotency key has expired. Reconcile the original submission before choosing a new key.
idempotency.key-reused409This idempotency key was used for a different request.
message.dispatch-started409The message is being sent or has been handed to the provider, so it can no longer be cancelled.
message.not-visible404No message with this identifier is visible to the caller.
message.terminal409The message has reached a final state, so it can no longer be cancelled.
operation.not-authorized403Your Messaging access profile does not allow this operation.
profile.not-authorized403No Messaging access profile authorizes this caller for this request.
quota.exceeded429Your Messaging access profile has accepted its daily limit of messages. Try again after the time in Retry-After.
rate-limit.exceeded429The request rate accepted from this caller is exceeded. Try again after the time in Retry-After.
request.body-too-large413The request body exceeds the accepted size.
request.invalid400The request could not be read as a Messaging request.
request.method-not-allowed405The route exists but not for this method.
request.not-found404The requested route does not exist.
request.unprocessable422The request body could not be processed.
request.unsupported-media-type415The request body is not JSON.
service.unavailable503Messaging is unavailable. Try again after the service recovers.
template.data-invalid422The data does not match the template's data schema.
template.locale-unavailable422The template version has no content in the requested locale. Choose one of its declared locales.
template.not-found404No template with this identifier and version is available to the caller.
template.render-refused422The template could not be rendered with this data within the rendering limits.
RouteAuthenticationPurpose
GET /healthNone200 while the process serves
GET /readyNone200 when the database carries every expected migration and its package ledger names the package this process serves active, 503 service.unavailable otherwise
POST /v1/messagesBearer, senderSubmit one message
GET /v1/messages/{message_id}Bearer, the submitting principal or an operatorRead one message’s status and attempts
POST /v1/messages/{message_id}/cancelBearer, the submitting principal or an operatorCancel a queued message
POST /v1/templates/{template_id}/versions/{version}/previewBearer, senderRender a listed template version with given data, persisting nothing
POST /v1/provider-callbacks/{provider_id}The provider’s callback verifierTake one delivery callback
POST /v1/provider-callbacks/{provider_id}/{token}A path-token verifierTake one delivery callback

GET /metrics is not part of this surface: it is served only on the separate metrics listener, and the public listener answers it 404 request.not-found.

The implementation is the source of truth, and the generated document is the published form of it. The product generator builds the OpenAPI document from the Rust route inventory, wire schemas, and problem catalogue, and the product checkpoint regenerates it and refuses byte drift. A running Messaging process serves the routes in that document, and does not serve the document itself.

The problem code table is generated from maintained data whose codes and statuses a site test holds to the generated document. The other tables on this page are maintained by hand against the cited source.