Released docs. You are viewing the documentation published with v0.39.0. Development docs are available at Latest.
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.
Contract boundary
Section titled “Contract boundary”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.
Access profiles
Section titled “Access profiles”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.
| Role | May | Refused with |
|---|---|---|
sender | Submit 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 submitted | profile.not-authorized for an unlisted sender profile or template, or for direct content the profile does not allow |
operator | Read and cancel every message | operation.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.
Limits
Section titled “Limits”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:
| Code | Refused when | Retry-After |
|---|---|---|
rate-limit.exceeded | The caller spent its burst | When the caller’s rate admits a submission again |
quota.exceeded | The profile accepted dailyLimit messages in the last 24 hours | When 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+jsonretry-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":"…"}Submission
Section titled “Submission”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.
Message status
Section titled “Message status”GET /v1/messages/{message_id} answers two facts separately, and one status derived from them:
dispatchis what the worker did with the message:queued,sending,submitted,failed,unknown,cancelled, orexpired.submittedmeans the provider accepted the message, never that it was delivered.reportis what the provider’s delivery receipts said:none,sent,delivered, orundelivered, andreportedAtrecords when. A report only moves forward, anddeliveredandundeliveredare final.unavailablemeans the message’s provider records no receipts, sosubmittedis the last status the message reaches.statusis the dispatch state, except that a submitted message reporteddeliveredisdelivered, and one reportedundeliveredisfailed.
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.
Provider callbacks
Section titled “Provider callbacks”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.
Problem codes
Section titled “Problem codes”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.
| Code | Status | When |
|---|---|---|
authentication.refused | 401 | The bearer credential is missing, invalid, or expired. Sign in again. |
callback.unreadable | 422 | The callback was verified, but the provider's receipt script could not read a delivery report from it. |
callback.unverified | 403 | The callback did not verify for a provider this deployment accepts callbacks from. |
content.invalid | 422 | The content does not have the parts the channel requires, or the template's channel differs from the sender profile's. |
content.too-large | 422 | A content part exceeds the size the channel accepts. Shorten the content or the data. |
content.too-many-segments | 422 | The SMS text needs more segments than the sender profile allows. Shorten the text or the data. |
idempotency.expired | 410 | The stored response for this idempotency key has expired. Reconcile the original submission before choosing a new key. |
idempotency.key-reused | 409 | This idempotency key was used for a different request. |
message.dispatch-started | 409 | The message is being sent or has been handed to the provider, so it can no longer be cancelled. |
message.not-visible | 404 | No message with this identifier is visible to the caller. |
message.terminal | 409 | The message has reached a final state, so it can no longer be cancelled. |
operation.not-authorized | 403 | Your Messaging access profile does not allow this operation. |
profile.not-authorized | 403 | No Messaging access profile authorizes this caller for this request. |
quota.exceeded | 429 | Your Messaging access profile has accepted its daily limit of messages. Try again after the time in Retry-After. |
rate-limit.exceeded | 429 | The request rate accepted from this caller is exceeded. Try again after the time in Retry-After. |
request.body-too-large | 413 | The request body exceeds the accepted size. |
request.invalid | 400 | The request could not be read as a Messaging request. |
request.method-not-allowed | 405 | The route exists but not for this method. |
request.not-found | 404 | The requested route does not exist. |
request.unprocessable | 422 | The request body could not be processed. |
request.unsupported-media-type | 415 | The request body is not JSON. |
service.unavailable | 503 | Messaging is unavailable. Try again after the service recovers. |
template.data-invalid | 422 | The data does not match the template's data schema. |
template.locale-unavailable | 422 | The template version has no content in the requested locale. Choose one of its declared locales. |
template.not-found | 404 | No template with this identifier and version is available to the caller. |
template.render-refused | 422 | The template could not be rendered with this data within the rendering limits. |
Routes
Section titled “Routes”| Route | Authentication | Purpose |
|---|---|---|
GET /health | None | 200 while the process serves |
GET /ready | None | 200 when the database carries every expected migration and its package ledger names the package this process serves active, 503 service.unavailable otherwise |
POST /v1/messages | Bearer, sender | Submit one message |
GET /v1/messages/{message_id} | Bearer, the submitting principal or an operator | Read one message’s status and attempts |
POST /v1/messages/{message_id}/cancel | Bearer, the submitting principal or an operator | Cancel a queued message |
POST /v1/templates/{template_id}/versions/{version}/preview | Bearer, sender | Render a listed template version with given data, persisting nothing |
POST /v1/provider-callbacks/{provider_id} | The provider’s callback verifier | Take one delivery callback |
POST /v1/provider-callbacks/{provider_id}/{token} | A path-token verifier | Take 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.
Source of truth
Section titled “Source of truth”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.
- Registry Messaging API operations for every route, parameter, and schema.
- Author a Messaging package for the access profiles, sender profiles, and templates a deployment serves.
- Deploy Registry Messaging for the runtime, its database, and the operator commands.