marketing, retention and collection fail closed even with Niadra out of reach, and it is the one barrier Niadra is not.
Before you start
- The
coordinationfeature on in the space. - The gateway declared in the
coordinationdocument: agateway_id(^[a-z][a-z0-9_]{0,39}$), the channels it serves andsecret_ref, the reference to the 32-byte key it shares with Niadra, written to the space’s vault throughPUT /v1/secrets/{kind}/{secret_id}. A channel with one gateway needs nogateway_idin the check; with several, the check names one, or is refused. - The purposes that need a token, in
purposes[].token:marketing,retentionandcollectionby default. Customer messages,transactionalandservicenever need one. - A source key for the gateway, with which it reads the space’s public keys (the route is public) and, if it wants, declares
contact.made(thecoordinatescope).
The path of a message
- The agent checks (
POST /v1/coordination/check) withdirection: outbound, the purpose, the channel and, when the channel has more than one gateway, thegateway_id. Anallowof a purpose that needs a token carriescontact_token. - The agent hands the message to the gateway with the token in a header or a metadata field of the dispatch request, never in the URL.
- The gateway checks the token against the message’s channel and destination, lets the message out once and declares
contact.madewith the token’sjti. - A message without a token, or with a refused token, does not leave. The agent does not retry the same token: it asks for a new decision.
The token
kid (the key that signed), space, jti (the token’s id, a UUIDv7 equal to the check’s decision_id), purpose, channel, rcpt, gateway, iat and exp (in Unix seconds, exp at most 120 seconds after iat). The signature is Ed25519 over the ASCII bytes of nct1.<payload>, as they appear in the token. Nothing in it is personal data: the destination goes only as rcpt, the base64url HMAC-SHA256 of the destination’s canonical form (phone:+5511987654321, email:ana@example.com) with the gateway’s shared key, which only the gateway and Niadra hold. A token is at most 1,024 characters.
The public keys
GET /.well-known/niadra-contact-keys.json?space=<space id> returns the space’s JWK set (RFC 8037: kty: OKP, crv: Ed25519, x, kid, space, status, not_after). A key is active or retiring: a retiring one signs nothing new and still verifies what it signed, until not_after; Niadra rotates by publishing the next one as active and the previous one as retiring for at least the life of a token. Cache the set, refresh it at least hourly and, on an unknown kid, refresh at most once a minute before refusing. The answer comes with Cache-Control: public, max-age=300. A gateway never stops checking because Niadra is out of reach: it keeps the last set it holds, however old, and refuses a token it cannot check.
Checking
The SDKs ship the check, with the keys read and kept for you:malformed, unsupported_version, unknown_key, bad_signature, wrong_space, wrong_gateway, lifetime_too_long, not_yet_valid (iat more than 5 seconds in the future), expired (exp passed, with 5 seconds of leeway), wrong_channel, wrong_recipient (a constant-time comparison) and replayed. After accepting, it remembers the jti until exp + 5 seconds and refuses it again; a gateway with several processes shares that memory (the SeenTokens interface in Python and TypeScript, with add(jti, until)) or routes a token’s messages to one process. MemorySeen is one process’s memory. A destination that is not a valid phone number or e-mail address is invalid_handle, and a handle type outside the list, unsupported_type.
A gateway that does not run the SDKs checks the same steps with any Ed25519 and HMAC library: the open specification spec/contact-token.md, in the public niadra-spec repository, fixes each step, and vectors/contact-token.v0.json holds test tokens with the expected result of each, with a test private key, never a real one.
Declaring the contact
contact.made closes the loop: with the token’s jti, the contact counts once in the contact log and in the purpose’s budget; two contact.made with the same jti show a token used twice, which the shadow report counts in tokens_used_twice. A message that left on a check Niadra did not answer (service out of reach) is declared with unchecked: true, and counts in the budget all the same. The agent that makes the check may declare instead of the gateway (conversation.declare.contact_made(decision, ...) in the SDKs); what matters is that someone declares.
When the dispatch is third-party
Where the dispatch point is third-party software that checks no token, coordination is shadow plus suppression: the shadow report counts the conflicts a check would have pointed out, and the suppression list stays the floor the third party honors on its own, by reading it every 60 seconds.Next steps
Coordination
the check, the decision and effects that happen exactly once.
Contact token public keys
the reference of the public route.
Declare what happened
contact.made and the other declarations.Retail agents
a marketing budget and the WhatsApp gateway.

