Gateman
Documentation/Gateman Contract v1

Gateman Contract v1

Contract v1 is Gateman's language-neutral HTTP transport and authentication protocol. It defines how an application constructs an authenticated request to an authenticated Gateman route and how transport-level responses are represented.

It does not define the payload, provider semantics, billing rules, operation idempotency, or response schema of every capability. Those remain part of the relevant capability contract. It also does not require a particular programming language, application architecture, or client library.

Canonical client guides are separate implementations of this same protocol:

Applicability

Contract v1 applies to authenticated application API routes. GET /health and GET /client/php are public routes and do not use application authentication. Provider webhooks and other externally authenticated routes use their own authentication contracts.

The shared protocol supplies an authenticated request envelope. The selected route then applies its own method, body, scope, environment, provider, and response rules.

Request construction

Authenticated application requests use the following headers:

HeaderRequirement
X-GM-AppRequired application identifier. The server trims it and requires a non-empty value no longer than 128 bytes.
X-GM-TsRequired decimal Unix timestamp in seconds.
X-GM-SigRequired HMAC-SHA256 or Ed25519 signature, according to the application's configured authentication mode.
X-GM-Idempotency-KeyOptional. Its normalized value is included in the signature.
X-GM-Credential-Request-IDRequired only for POST /secrets/get and POST /db/get.
Content-TypeNot validated by the shared authentication layer. JSON routes conventionally use application/json.

The authenticated capability routes currently use POST. The public health route uses GET.

The request URL must contain the Gateman base URL and a path beginning with /. The server obtains the path from the request URI without its query string. Query parameters therefore do not participate in the signing input or route selection. Implementations should omit query strings from Contract v1 request paths; canonical clients reject query-bearing paths locally.

The server does not apply a shared URL path canonicalization algorithm beyond extracting the URL path. A direct implementation must sign the exact path value that the server receives after this extraction. Canonical clients additionally reject fragments, controls, unencoded whitespace or Unicode, transport-normalizing forms, backslashes, and dot path segments, while accepting already percent-encoded paths that remain transport-equivalent.

Request body

The signature covers the exact raw request-body bytes received by Gateman. The server does not parse and reserialize the body before authentication.

For JSON routes:

  • JSON is normally sent as UTF-8 with Content-Type: application/json.
  • Whitespace, object-key order, escaping, Unicode representation, and numeric representation are significant because the serialized bytes are signed.
  • The server does not perform JSON canonicalization.
  • An empty body is accepted by the shared body reader and becomes an empty route payload. Whether that is valid is route-specific.
  • Invalid JSON and JSON scalar values are rejected by routes that use the shared JSON body reader, normally with HTTP 400.

No single shared application-level request-size limit is defined by the Contract v1 authentication layer. Individual routes may enforce their own limits, and deployment infrastructure may impose additional limits.

Signing input

All signing inputs are byte sequences formed with the literal line-feed byte (0x0a) as the separator.

For an ordinary authenticated request:

<timestamp>\n
<uppercase HTTP method>\n
<URL path without query string>\n
<normalized idempotency key>\n
<raw request body>

The notation above shows line boundaries. There is no extra newline after the body unless the body itself ends with one.

For a credential-disclosure request:

<timestamp>\n
<uppercase HTTP method>\n
<URL path without query string>\n
<normalized idempotency key>\n
<credential request ID>\n
<raw request body>

The empty components are retained. For example, an ordinary request with no idempotency key has two consecutive newline delimiters after the path:

1700000000
POST
/secrets/get

{"keys":["PHASE1_TEST_VALUE"],"environment":"production"}

The method is uppercased for signing. The server currently dispatches the authenticated capability routes as POST, so an independent implementation should send POST for those routes.

HMAC-SHA256 authentication

For an HMAC-authenticated application, calculate HMAC-SHA256 over the signing input using the application's application secret. Send the result as lowercase hexadecimal text in X-GM-Sig.

The server compares HMAC values in constant time. Uppercase hexadecimal is accepted by the current server after normalization, but lowercase hexadecimal is the canonical encoding.

Ed25519 authentication

For an Ed25519-authenticated application, calculate a detached Ed25519 signature over the signing input using the application's private key. Encode the 64 signature bytes as canonical unpadded Base64url and send that value in X-GM-Sig.

Padding (=), alternate Base64 encodings, invalid Base64url characters, and signatures that do not decode to exactly 64 bytes are rejected.

The server verifies the signature against the application's stored 32-byte Ed25519 public key.

Timestamp validation

X-GM-Ts must contain only decimal digits and is interpreted as Unix time in seconds. The server accepts a request when the absolute difference between the current server time and the supplied timestamp is no more than 300 seconds.

The timestamp text, including any leading zeroes, is part of the signed input. Timestamp validation provides freshness, not one-time request execution.

Idempotency-header binding

X-GM-Idempotency-Key is optional. Before signing, the server trims ASCII spaces (0x20) and tabs (0x09) from both ends of the header value.

The following all produce an empty signed component:

  • an absent header;
  • an empty value;
  • a value containing only spaces and tabs.

The normalized value occupies the fourth signing-input component. A non-empty value is also sent as the request header by canonical clients.

Contract v1 authenticates the normalized header value. It does not, by itself, provide operation-level idempotency, uniqueness, replay storage, retention, response replay, or usage-accounting behavior. Those behaviors are capability-specific.

Credential-disclosure request protection

POST /secrets/get and POST /db/get require X-GM-Credential-Request-ID.

The value must match exactly:

[a-f0-9]{32}

It is included as a separate signed line between the idempotency-key component and the raw body. The server validates the identifier during authentication and claims it after the route has validated its request-specific body fields.

A successful claim is single-use for the authenticated application. Reusing the same identifier for the same application returns HTTP 409. Claims are retained for 11 minutes; expired claims may be removed and reused after expiration. This mechanism is distinct from ordinary idempotency.

Canonical clients generate a fresh 32-hex-character identifier for each credential-read call when the caller does not provide one. An implementation that sends one of these routes directly must generate and transmit the header itself.

Environment boundaries

Contract v1 does not assign an application environment. An application must provide an environment where the selected route requires one; Gateman does not silently choose production, staging, or development.

The currently environment-aware routes are:

  • POST /send/ses
  • POST /send/postmark
  • POST /send/resend
  • POST /email/readiness
  • POST /twilio/sms/send
  • POST /telnyx/sms/send
  • POST /aws/sms/send
  • POST /stripe/request
  • POST /square/request
  • POST /paypal/request
  • POST /stripe/webhook-secret
  • POST /stripe/webhook/setup
  • POST /config/get
  • POST /secrets/get

For these routes, the route body must contain an environment value accepted by that route. Current accepted values are production, staging, and development; route validators trim and lowercase the value.

The following shared routes are environment-independent in the current implementation: /client/get, /db/get, /turnstile/get, /recaptcha/get, /s3/presign, and /r2/presign. /secrets/get requires an environment field; custom secrets use the production value for production and the shared test value for staging and development, with no fallback. Provider-managed retrieval keeps its existing provider rules.

An application's environment is distinct from Gateman's private deployment environment. Canonical-client configuration may inject the caller's environment into environment-aware request bodies, but that is client behavior, not a Contract v1 server default.

Authentication, authorization, and admission

The shared request lifecycle is:

  1. Read the authentication headers.
  2. Validate the credential request ID when the path requires it.
  3. Resolve the application from Gateman's application registry.
  4. Require an active application and valid configured key material.
  5. Enforce any configured source-IP restriction.
  6. Validate timestamp syntax and freshness.
  7. Read the raw body and verify the signature.
  8. Check the route's required capability scope.
  9. Enter shared per-application request admission.
  10. Dispatch to the route-specific contract.

Authentication, application resolution, timestamp, signature, scope, and source-IP failures are returned as HTTP 401 JSON responses with the general shape:

{"ok":false,"error":"..."}

The precise error string is server-controlled and must not be used as a substitute for the HTTP status and route contract.

Shared application admission may reject an otherwise authenticated request with HTTP 429 and Retry-After, or HTTP 503 if the admission mechanism is unavailable. These controls are distinct from shared provider-bound concurrency controls, provider responses, capability operation accounting, and capability-specific idempotency.

Responses

Gateman responses include, where applicable:

Content-Type: application/json; charset=utf-8
Cache-Control: no-store
X-Gateman-Request-ID: <16 lowercase hexadecimal characters>

The request ID is a response correlation identifier. It is not an authentication value and is not included in the request signature.

HTTP status is authoritative. A non-2xx status is still a valid HTTP response and is not a transport failure.

The server commonly emits JSON for its own errors and route responses, but Contract v1 does not define a universal JSON success or error envelope. Capability routes may return their own JSON shapes. Provider relay routes may preserve a provider-native status, body, and selected response headers. Non-JSON and binary bodies are valid response possibilities at the transport boundary and must not be discarded by an implementation.

Retry-After may be returned for admission, rate-limit, or provider-related conditions. Its presence and meaning are route- or failure-specific.

Authentication and admission failures are server-generated JSON responses. Transport failures, such as connection failure or timeout before a valid HTTP response is received, are client-side transport errors and have no Gateman HTTP status or response body.

Public discovery boundary

POST /client/get is an authenticated, app-scoped discovery request requiring client:read. It returns the current application integration context. It is not a replacement for the Contract v1 request protocol and does not change how subsequent requests are signed.

GET /client/php is a public artifact route that returns the canonical PHP client as plain text. It is a client-acquisition mechanism, not an authenticated Contract v1 request.

Security properties and limitations

Contract v1 establishes:

  • application authentication using HMAC-SHA256 or Ed25519;
  • integrity protection for the timestamp, method, path, normalized idempotency key, credential request ID when applicable, and raw request body;
  • timestamp freshness within the server's five-minute acceptance window;
  • application status and capability-scope enforcement;
  • optional source-IP restrictions configured for the application;
  • single-use credential-request claims for the two credential-disclosure routes;
  • shared application admission before route execution.

Contract v1 does not establish:

  • universal one-time use for ordinary authenticated signatures;
  • operation-level idempotency for every route;
  • provider-side idempotency or replay behavior;
  • confidentiality independent of the HTTP deployment's transport security;
  • a universal response envelope;
  • a universal request-size limit;
  • a capability's provider authorization or provider resource ownership rules.

Applications must use transport confidentiality appropriate to their deployment. The signing protocol provides authentication and integrity; it does not replace HTTPS.

Verification vectors

The following vectors use an illustrative timestamp. A request using 1700000000 is not expected to pass freshness validation today; the vector is for byte-level implementation verification.

Ordinary HMAC (POST /client/get)

POST /client/get uses the ordinary signing format because it does not require a credential-disclosure request identifier. Its {} body is accepted as an empty parameter object; a real request also requires an active application with the client:read scope.

Secret:

test-secret

Signing input:

1700000000
POST
/client/get

{}

Expected lowercase hexadecimal HMAC-SHA256:

e61fc075196319e39f2b0c9a026d28e17725867e74997d1ad244f9b1a8d1fc88

Ordinary Ed25519 (POST /client/get)

The 32-byte private seed, in canonical Base64, is:

AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=

Using the same signing input as the ordinary HMAC vector, the expected unpadded Base64url detached signature is:

ldnosHQ5f78Pio0R0wp2CXjWXeV1p5vaUqfuco5LUZk1l4mUhFpFApoW2wH4qR8lzKJXV5AAU5VIaesazowmAQ

Idempotency-key binding

The ordinary-format idempotency example also uses POST /client/get, so it does not require a credential request identifier. With normalized idempotency key stable-key, the signing input is:

1700000000
POST
/client/get
stable-key
{}

Using test-secret, the expected HMAC-SHA256 value is:

b91dcebad17a5ab5f20410537612c1811837548497e0693069f79d8b099df4cf

Changing, removing, or replacing the idempotency-key component changes the signed bytes and therefore the expected signature.

Credential-request binding

With the same idempotency key and credential request ID 00112233445566778899aabbccddeeff, the signing input is:

1700000000
POST
/secrets/get
stable-key
00112233445566778899aabbccddeeff
{"environment":"production"}

Using test-secret, the expected HMAC-SHA256 value is:

a6fd4d1364f95d8b5305d8506fb21a745183c950c2443183ceeaf9079573d437

Raw-body sensitivity

For an ordinary-format request such as POST /client/get, these two body byte sequences are different signing inputs and must produce different signatures. They are byte-level sensitivity examples; unlike {}, they are not valid parameter bodies for /client/get.

{"a":1}
{"a":1 }

An implementation must sign the exact bytes it transmits, without parsing and reserializing between signature creation and HTTP transmission.

Separate capability contracts

Contract v1 deliberately stops at the shared authenticated transport boundary. Consult the relevant capability reference for payload fields, required scopes, environment-specific validation, provider behavior, operation accounting, provider-native response details, and capability-specific idempotency.