Skip to main content
A coordination decision is advice until the point that sends the message enforces it. That point is yours: the WhatsApp gateway, the e-mail sender, the middleware every outbound message goes through. The contact token lets that point enforce the decision without calling anyone: Niadra signs, with the space’s Ed25519 key, that one outbound contact of one purpose, on one channel, to one destination, through one gateway, may leave in the next two minutes, and the gateway checks the signature and the fields with the public keys it already holds in memory. Without a valid token, the message does not leave. That is how 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 coordination feature on in the space.
  • The gateway declared in the coordination document: a gateway_id (^[a-z][a-z0-9_]{0,39}$), the channels it serves and secret_ref, the reference to the 32-byte key it shares with Niadra, written to the space’s vault through PUT /v1/secrets/{kind}/{secret_id}. A channel with one gateway needs no gateway_id in the check; with several, the check names one, or is refused.
  • The purposes that need a token, in purposes[].token: marketing, retention and collection by default. Customer messages, transactional and service never 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 (the coordinate scope).

The path of a message

  1. The agent checks (POST /v1/coordination/check) with direction: outbound, the purpose, the channel and, when the channel has more than one gateway, the gateway_id. An allow of a purpose that needs a token carries contact_token.
  2. 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.
  3. The gateway checks the token against the message’s channel and destination, lets the message out once and declares contact.made with the token’s jti.
  4. 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

The payload is compact JSON, with its members in this order: 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:
The gateway checks thirteen steps in order and refuses with the first code that applies: 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.