Target configurations for upcoming versions
GuideOperationsSince version 1.6.0
On this page
- MQTT devices: X.509 or token, topic bound to the tenant and the certificate
- Sparkplug B
- OPC UA PubSub JSON over MQTT
- Plans read from a claim, a default plan
- Inference budgets per user and per use case
- Incoming Stripe webhook and secret rotation
- Generic HMAC webhook
- Standard Webhooks, a shared replay memory
- Address lists: a global deny list and ip-filter
- Negotiated compression
- Coalescing in-flight reads
Operations guide: configure, deploy and observe the gateway. What the capability does, product side: product guide; calling it: developer guide.
This page shows, for each planned use case, the target shape of a complete flexway.yaml and the
contract excerpt that goes with it, and the version meant to deliver it: 1.7.0 (devices and industry)
and 1.8.0 (enterprise needs). Nothing below is built: a detail may still move before the version
ships, and this page will follow. Where the shape of a key is not fixed yet, the example says so rather
than invent it. Each example will become a file of examples/, validated like the others, with its
version.
Already delivered in
1.6.0, and so described elsewhere: the native Kafka proxy (Native Kafka clients: a virtual cluster per tenant), field encryption (Field encryption), Protobuf, Avro and CBOR payloads under schema and the schema registry (Avro payloads under schema, schema registry), NATS and AMQP 1.0 (Examples: Kafka, RabbitMQ, MQTT, Avro, NATS, AMQP 1.0).
What today's gateway does with these shapes (checked with flexway validate):
| The example declares | Today |
|---|---|
A new flexway.yaml key: listeners.mqtt, plans, webhooks, server.ip_deny, server.proxy_protocol, ai.budget.per_subject, ai.use_cases, ai.aliases, plan_claim, use_case_claim, trusted_clients, auth.mtls.clients[].plan |
Refused at load (field ... not found): the whole configuration is refused |
An x-flexway-bind: "cert:<field>" binding, a webhook scheme read from a header other than Authorization |
Refused at load |
An upcoming policy type: compress, ip-filter |
Accepted, never applied: flexway validate and the start warn about it (a later version - NOT applied) |
An upcoming contract extension: x-flexway-device, x-flexway-sparkplug, x-flexway-use-case, x-flexway-webhook, x-flexway-coalesce |
Refused at load (not served yet - planned for <version>): the whole contract is refused, like any unknown x-flexway-* extension (unknown extension, with the closest one when it is obvious) or one written where it is not read (not read here) - Not built yet |
Planned in 1.7
MQTT devices: X.509 or token, topic bound to the tenant and the certificate
Target of version 1.7.0 - not built yet: refused at load today (
listeners.mqtt,x-flexway-bind: "cert:cn",x-flexway-device).
The gateway is an MQTT proxy in front of the client's broker: one upstream connection per device, each packet judged by the same decision as an HTTP request. Sessions, retained messages, wills and QoS 2 stay with the broker.
# flexway.yaml - the listener; no topic here, they come from the contract
version: 1
contracts:
asyncapi: api/asyncapi.yaml
listeners:
mqtt:
address: ":8883"
tls: { cert_file: /tls/tls.crt, key_file: /tls/tls.key, client_ca_file: /tls/ca.crt } # the devices' X.509
websocket: { path: /mqtt }
max_packet_bytes: 262144
connect_timeout: 5s # a slow CONNECT is closed
upstreams:
plant-broker:
protocol: mqtt
brokers: ["mqtts://broker.plant.internal:8883"]
protocol_version: 5
auth:
issuers: # for a device without a certificate: a token as the password
- name: devices
discovery: https://id.example.com/.well-known/openid-configuration
audience: plant-devices
tenant_claim: site# api/asyncapi.yaml - the listeners' convention, the native Kafka proxy's (1.6.0):
# the extension on the operation, send = the device publishes, receive = it subscribes
channels:
telemetry:
address: "plant/{site}/line/{line}/{device}/telemetry"
parameters:
site: { x-flexway-bind: tenant } # the verified tenant, never the topic's
device: { x-flexway-bind: "cert:cn" } # the common name of the device's certificate
messages:
reading:
contentType: application/x-protobuf
payload: { schemaFormat: "application/vnd.google.protobuf;version=3", schema: { $ref: "./reading.binpb#Reading" } }
operations:
publishTelemetry:
action: send # the device publishes: writes to the channel
channel: { $ref: "#/channels/telemetry" }
x-flexway-upstream: plant-broker
x-flexway-device: { qos_max: 1, validate: full }
security: [{ deviceCert: [] }]| Packet | Decision |
|---|---|
CONNECT |
A verified certificate, else the password (a verified token or a key), else refused 0x87; the client id is rewritten, bound to the identity - a device never evicts another; the will is judged as a PUBLISH |
PUBLISH |
Topic -> declared channel, bound parameters equal to the verified values (site = tenant, device = CN), scope, quota, schema when validate: full; refused with PUBACK 0x87, 0x97 (quota), 0x99 (format) in MQTT 5, closed in 3.1.1 |
SUBSCRIBE |
Each filter, wildcards included, must fit inside a declared channel once the bound parameters are set; else SUBACK 0x87 |
Token exp |
DISCONNECT 0xA0 (MQTT 5); the device comes back with a fresh token |
Planned in 1.7
Sparkplug B
Target of version 1.7.0 - not built yet: refused at load today (
listeners.mqtt,x-flexway-bind: "cert:cn",x-flexway-sparkplug).
flexway.yaml is that of 12.29.6. A Sparkplug B 3.0 channel takes one word: the spBv1.0/...
namespace and the sparkplug_b.proto descriptor are built in.
# api/asyncapi.yaml - excerpt
channels:
sparkplug:
address: "spBv1.0/{group}/{type}/{edge_node}"
x-flexway-sparkplug: true
parameters:
group: { x-flexway-bind: tenant } # the Sparkplug group is the tenant
edge_node: { x-flexway-bind: "cert:cn" } # a node publishes under its own name only| Element | Handling |
|---|---|
| Birth and death | NBIRTH/DBIRTH -> online, NDEATH (will)/DDEATH -> offline, per tenant; bdSeq checked; seen in the console |
| Sequences | seq 0-255 checked per node, a gap counted (flexway.sparkplug.sequence.gaps), never blocking |
| Commands | NCMD/DCMD published by an application (an HTTP x-flexway-publish gate or MQTT) under a declared write scope |
Planned in 1.7
OPC UA PubSub JSON over MQTT
Target of version 1.7.0 - not built yet: refused at load today (
listeners.mqtt,x-flexway-device).
No extension of its own: an OPC UA PubSub publisher sending JSON over MQTT is a device of MQTT devices,
under an ordinary contract - a channel, its JSON schema, its bindings. The plant/{site}/ prefix
(configured in the publisher) binds the tenant; the rest follows the topic structure OPC UA
recommends (part 14).
# api/asyncapi.yaml - excerpt
channels:
opcua-data:
address: "plant/{site}/opcua/json/data/{publisher}/{writer_group}"
parameters:
site: { x-flexway-bind: tenant }
publisher: { x-flexway-bind: "cert:cn" } # the publisher sends under its own name
messages:
network-message:
contentType: application/json
payload: { $ref: "./opcua-network-message.json" } # the JSON NetworkMessage, validated
operations:
publishOpcuaData:
action: send
channel: { $ref: "#/channels/opcua-data" }
x-flexway-upstream: plant-broker
x-flexway-device: { qos_max: 1, validate: full }
security: [{ deviceCert: [] }]Planned in 1.8
Plans read from a claim, a default plan
Target of version 1.8.0 - not built yet: refused at load today (
plans,plan_claim).
Selling an API by plans: a caller's plan comes from a verified claim of its token, or from the
plan attribute of its API key or its certificate; its limits are declared here. A caller without a
plan, or whose plan is not declared, gets the default plan - never "unlimited".
# flexway.yaml
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://orders.internal:8080
server:
counters: { store: "${COUNTERS_URL}" } # required: refused at load without a shared store
auth:
issuers: # the existing key is auth.issuers
- name: corporate
discovery: https://id.example.com/.well-known/openid-configuration
audience: orders-api
tenant_claim: org
plan_claim: subscription.plan # a dotted path to a claim of the verified token
plans:
default: free # required as soon as plans: exists
count_per: tenant # tenant (default) | subject
definitions:
free:
requests: { day: 1000, month: 20000 }
tokens: { day: 50000, month: 1000000 }
eur: { month: 5 }
pro:
requests: { day: 100000, month: 2000000 }
tokens: { month: 50000000 }
eur: { month: 500 }# api/openapi.yaml - excerpt: nothing specific to plans, the contract only says who gets in
paths:
/orders:
get:
operationId: listOrders
security: [ { corporate: [orders:read] } ]| Rule | Behaviour |
|---|---|
| Unknown plan | The default plan, a plan_unknown log line and the flexway.plans.unknown counter - the caller is authenticated, the gap comes from its provider |
| With the tenant's quotas (Setting quotas) | Both apply, the first reached refuses: 429 quota_exceeded, member limit: plan |
| API key, certificate | The plan attribute of the store entry (written by flexway api-key) or of auth.mtls.clients[] |
Planned in 1.8
Inference budgets per user and per use case
Target of version 1.8.0 - not built yet: refused at load today (
ai.budget.per_subject,ai.use_cases,ai.aliases,use_case_claim,x-flexway-use-case).
On top of the tenant's budget (Serving inference), a budget per user (the verified subject) and per use
case. The use case comes from the contract (x-flexway-use-case), from a model alias of
/v1/*, or from the claim of a trusted client - never from a caller's header. The most
restrictive budget wins.
# flexway.yaml
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://tickets.internal:8080
auth:
issuers:
- name: corporate
discovery: https://id.example.com/.well-known/openid-configuration
audience: ai
tenant_claim: org
use_case_claim: ai_use_case # believed only for the clients of trusted_clients
trusted_clients: [batch-reviewer]
ai:
security: [ { oidc: { scopes: [inference] } } ]
default_backend: hosted
backends:
hosted:
url: https://api.provider.example/v1
api_key: "file:/etc/flexway/secrets/provider-key"
default_model: gpt-4.1-mini
external: true
price: { input_per_mtok: 0.40, output_per_mtok: 1.60 }
budget:
store: "${COUNTERS_URL}"
monthly_eur: 2000 # tenant, unchanged
per_subject: { monthly_eur: 20, daily_tokens: 200000 } # each verified subject
use_cases:
support-bot: { monthly_eur: 300 }
code-review: { monthly_eur: 800, daily_tokens: 5000000 }
aliases: # a /v1/* model name that carries a use case
support-assistant: { backend: hosted, model: gpt-4.1-mini, use_case: support-bot }# api/openapi.yaml - excerpt: an operation (or a metered gRPC method) carries its use case
paths:
/tickets/{id}/summary:
post:
operationId: summarizeTicket
x-flexway-use-case: support-bot # a use case missing from ai.use_cases: refused at load| Rule | Behaviour |
|---|---|
| A call without a subject | No user budget; the tenant's applies |
| Contract and claim | The contract wins: a trusted client does not move a marked operation to another budget |
| Refusal | 429 budget_exceeded, member limit: tenant, plan, subject or use_case (OpenAI envelope on /v1/*) |
| Soft cap | The tenant's only; a user or use case budget is a hard cap |
| Consumption per user | GET /usage/subjects (administration), subject hashed; never a metric dimension |
Planned in 1.8
Incoming Stripe webhook and secret rotation
Target of version 1.8.0 - not built yet: refused at load today (
webhooks,x-flexway-webhook, a scheme read fromStripe-Signature).
Receiving a provider's webhooks signed: the signature is the caller's identity, an alternative of
security in the contract. A preset is enough for Stripe, GitHub, Slack, Shopify and SendGrid.
# flexway.yaml - secrets by reference, never in clear
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://billing.internal:8080
webhooks:
secrets:
stripe-endpoint:
current: file:/secrets/stripe/current
previous: file:/secrets/stripe/previous # rotation: both verify
tenant: acme # bound like an API key
replay: { max_ids: 100000 } # ids seen, per replica, bounded# api/openapi.yaml - excerpt
components:
securitySchemes:
stripe:
type: apiKey
in: header
name: Stripe-Signature
x-flexway-webhook: { preset: stripe, secret: stripe-endpoint }
paths:
/hooks/stripe:
post:
operationId: stripeEvents
security: [{ stripe: [] }]Rotating the secret without losing an event:
| Step | Action |
|---|---|
| 1 | The old secret moves to previous, the new one to current (two keys of the mounted Secret); hot reload (Changing the configuration without a restart) - both verify |
| 2 | The provider switches to the new secret; a signature against either one is enough |
| 3 | Once the switch is done, previous is removed |
| Refusal | Response |
|---|---|
| Header missing or unreadable, wrong signature, timestamp out of tolerance, id already seen | 401 webhook_signature_invalid, without saying which; the cause goes to the log and to flexway.webhook.verifications{outcome} |
| Body past the bound | 413, before any verification |
The body is read whole and bounded before the verification, then relayed byte for byte; the signature headers stay (the upstream may verify again), the secret never leaves.
Planned in 1.8
Generic HMAC webhook
Target of version 1.8.0 - not built yet: refused at load today (
webhooks,x-flexway-webhook, a scheme read fromX-Signature).
A partner without a preset: the HMAC scheme is declared in full, as a closed template.
# flexway.yaml
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://partners.internal:8080
webhooks:
secrets:
partner-hooks:
current: file:/secrets/partner/current
tenant: partner-a
replay: { max_ids: 100000 }# api/openapi.yaml - excerpt
components:
securitySchemes:
partner:
type: apiKey
in: header
name: X-Signature
x-flexway-webhook:
secret: partner-hooks
algorithm: sha256 # sha256 | sha512; sha1 refused
signature: { header: X-Signature, prefix: "sha256=", encoding: hex }
timestamp: { header: X-Timestamp, format: unix }
id: { header: X-Delivery-Id }
signed: "{timestamp}.{body}" # closed template: {id}, {timestamp}, {body}
tolerance: 5m
paths:
/hooks/partner:
post:
operationId: partnerEvents
security: [{ partner: [] }]Planned in 1.8
Standard Webhooks, a shared replay memory
Target of version 1.8.0 - not built yet: refused at load today (
webhooks,x-flexway-webhook, a scheme read fromwebhook-signature).
A sender that follows Standard Webhooks, the format of Flexway's
outgoing webhooks (Publishing and consuming on a broker): HMAC-SHA256 (v1) or Ed25519 (v1a), id and timestamp signed. With
several replicas, the memory of the ids seen can be shared (a declared option): without it, a
replay on another replica within the tolerance window gets through.
# flexway.yaml
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://events.internal:8080
server:
counters: { store: "${COUNTERS_URL}" } # the shared memory lives in the counters' store
webhooks:
secrets:
platform-events:
current: file:/secrets/platform/current # whsec_<base64>
previous: file:/secrets/platform/previous
tenant: acme
replay: { max_ids: 100000, shared: true } # a timeout, and a refusal if the store does not answer# api/openapi.yaml - excerpt
components:
securitySchemes:
platform:
type: apiKey
in: header
name: webhook-signature
x-flexway-webhook: { preset: standard-webhooks, secret: platform-events }
paths:
/hooks/platform:
post:
operationId: platformEvents
security: [{ platform: [] }]| Preset | Replay |
|---|---|
standard-webhooks, stripe, slack, sendgrid |
Signed timestamp: tolerance and ids seen |
github, shopify |
Unsigned id: partial protection, said by /config |
Planned in 1.8
Address lists: a global deny list and ip-filter
Target of version 1.8.0 - not built yet: refused at load today (
server.ip_deny,server.proxy_protocol). On its own, anip-filterpolicy is accepted with a warning and is not applied.
Two gates: server.ip_deny, a global deny list judged before routing (a threat feed costs
neither routing nor cryptography); the ip-filter policy, attached to a host, a route or an
operation, judged after routing and before CORS and identity.
# flexway.yaml
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://shop.internal:8080
server:
trusted_proxies: [10.0.0.0/8] # X-Forwarded-For believed from these peers only (already exists)
proxy_protocol: { from: [10.0.5.0/24] } # v1 and v2, from these peers only; read at start
ip_deny:
inline: [203.0.113.0/24]
files: [/lists/threat-feed.txt] # one range per line, # comment; read again at reload
policies:
definitions:
office-only: { type: ip-filter, config: { allow: [198.51.100.0/24, "2001:db8::/32"] } }
stripe-ranges: { type: ip-filter, config: { allow_files: [/lists/stripe-ips.txt] } }
chains:
hosts: { admin.example.com: [office-only] }# api/openapi.yaml - excerpt: the operation level of the chain, in the contract like any policy
paths:
/hooks/stripe:
post:
operationId: stripeEvents
x-flexway-policies: [stripe-ranges] # the ranges the provider publishes| Rule | Behaviour |
|---|---|
| Client address | The TCP peer; behind a peer of trusted_proxies, X-Forwarded-For read right to left up to the first untrusted hop; PROXY protocol from another peer: the connection is closed |
ip-filter |
allow: the address must be in it; deny: refused if it is; deny wins; the most precise chain wins, !office-only removes it |
| Unknown route | The host's chain is judged before 404 or 405: a refused caller gets 403 and does not map the routes |
| Refusal | 403 address_denied, before any cryptography; flexway.ip.refused{level} |
| File unreadable at reload | The previous configuration stays - never an empty list put in its place |
Planned in 1.8
Negotiated compression
Target of version 1.8.0 - not built yet: today, the
compresspolicy is accepted with a warning (a later version - NOT applied) and is not applied.
gzip, brotli and zstd, negotiated by Accept-Encoding; opt-in through a policy - without it, nothing
changes.
# flexway.yaml
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://catalog.internal:8080
policies:
definitions:
compress-json:
type: compress
config:
encodings: [zstd, br, gzip] # the server's preference at equal quality
min_bytes: 1024 # default fixed by bench M1
types: [application/json, "application/*+json", "text/*"] # an allow list
chains:
global: [compress-json]# api/openapi.yaml - excerpt: nothing to declare; a stream is never compressed
paths:
/products:
get:
operationId: listProducts| Rule | Behaviour |
|---|---|
| Eligible | Known length of at least min_bytes, a type of the allow list, no Content-Encoding from the upstream, a method other than HEAD; a stream (SSE, unknown length, 101) never |
| Headers | Content-Encoding, Content-Length recomputed, Vary: Accept-Encoding on every eligible response, a strong ETag made weak |
| Levels | Fixed by bench, one constant per algorithm: no key |
| Cache (Caching a read) | One entry per chosen encoding, never per raw Accept-Encoding value |
| BREACH | A response that mixes a secret and a reflected input: exclude it with a when condition |
Planned in 1.8
Coalescing in-flight reads
Target of version 1.8.0 - not built yet: refused at load today (
x-flexway-coalesce).
A burst of identical reads while a first one is in flight: one upstream call, the response copied to
each waiter. Opt-in per operation, in the contract; the key is the cache's - tenant and
subject for an operation that requires an identity. Each waiter was admitted and counted:
coalescing spares the upstream, never the quota. flexway.yaml has nothing new.
# flexway.yaml - nothing specific to coalescing
version: 1
contracts:
openapi: ./openapi.yaml
upstreams:
default:
url: http://catalog.internal:8080# api/openapi.yaml - excerpt
paths:
/catalog/products:
get:
operationId: listProducts
x-flexway-cache: { ttl: 30s } # optional: the cache first, coalescing on its misses
x-flexway-coalesce: { max_waiters: 256, max_bytes: 1MiB }| Rule | Behaviour |
|---|---|
| Refused at load | A method other than GET or HEAD, a stream, WebSocket, a gRPC method, inference |
| Not shareable | Set-Cookie, unknown length, past max_bytes: each waiter calls the upstream |
Past max_waiters |
The call goes its own way (flexway.coalesce.requests{role="overflow"}) |
| Upstream error | Shared like a response; a timeout of the leader is not |