flask-integration
A small framework for building integrations in Flask applications:
- key-authenticated JSON API endpoints, declared with decorators and typed with Pydantic v2
- outgoing webhooks with typed payloads — subscribed URLs are called when your app triggers the event
- an OpenAPI 3.1 document per integration (including the native
webhookssection) at<base>/openapi.json - a
flask integrationCLI to manage API keys and webhook subscriptions and to inspect what's declared in code - storage in your app's database, with schema contributed to live-alembic via the
live_alembic.modelsentry point - an optional debug log of every action the framework takes
Requires Python ≥ 3.12, Flask ≥ 3, Pydantic ≥ 2, SQLAlchemy ≥ 2.
Declaring an integration
from flask import Flask
from pydantic import BaseModel
from flask_integration import FlaskIntegration, FlaskIntegrationBase, api_endpoint, webhook
class CreateOrder(BaseModel):
sku: str
quantity: int
class Order(BaseModel):
order_id: int
sku: str
quantity: int
class OrderCreated(BaseModel):
order_id: int
total: float
class ShopIntegration(FlaskIntegrationBase):
name = "shop" # default: class name without "Integration", snake_case
title = "Shop integration API" # OpenAPI title
@api_endpoint(methods=["POST"], summary="Create an order")
def create_order(self, body: CreateOrder) -> Order:
# request body validated into CreateOrder; returned model is serialized
return Order(order_id=1, sku=body.sku, quantity=body.quantity)
@api_endpoint # GET /ping
def ping(self):
"""Liveness check."""
return {"pong": True}
@webhook(payload=OrderCreated)
def order_created(self):
"""Fired when an order is created."""
app = Flask(__name__)
app.config["INTEGRATION_DATABASE_URI"] = "sqlite:///app.db"
shop = ShopIntegration()
FlaskIntegration(app, shop, url_prefix="/api/integrations/shop")
@api_endpointinfers the request body model from the first Pydantic-annotated parameter and the response model from the return annotation (request_model=/response_model=override). The URL rule defaults to the method name with hyphens; pass a rule explicitly with@api_endpoint("/orders", methods=["POST"]).- Parameters named in the rule receive the URL values, with Flask converters:
@api_endpoint("/spaces/<space>/pages/<int:page_id>")→def page(self, space: str, page_id: int). OpenAPI shows/spaces/{space}/pages/{page_id}. - A model parameter is read from the query string when it is marked
Annotated[Model, Query]; on an endpoint without POST / PUT / PATCH the first model parameter is too. Repeated keys and list fields become lists, booleans acceptyes/true/1.query_model=overrides. A further model parameter on such an endpoint would be a request body and is aTypeErrorat registration (unlessrequest_model=is given). - The return annotation can be any type pydantic serializes —
-> list[Order],-> dict[str, Order]— and is documented as such;-> Nonedocuments a 204. - Raise
ApiError(404, "not_found", "no such order")(or a subclass withstatus/codeclass attributes, raised asNotFound(detail=...)) to answer{"error": "not_found", "detail": "no such order"}.abort()and the 401 / 422 responses use the same shape. Document them with@api_endpoint(..., errors={404: "No such order"}).
from typing import Annotated
from flask_integration import ApiError, Query
class OrderFilter(BaseModel):
status: list[str] = []
limit: int = 50
class ShopIntegration(FlaskIntegrationBase):
@api_endpoint("/orders") # GET /orders?status=open&limit=10
def list_orders(self, filters: OrderFilter) -> list[Order]: ...
@api_endpoint("/orders/<int:order_id>", errors={404: "No such order"})
def get_order(self, order_id: int) -> Order:
raise ApiError(404, "not_found", f"no order {order_id}")
@api_endpoint("/orders/<int:order_id>", methods=["PATCH"])
def update_order(self, order_id: int, body: CreateOrder,
opts: Annotated[OrderFilter, Query]) -> Order: ...
- Subclassing an integration inherits its endpoints and webhooks. Overriding
an endpoint method without the decorator keeps the endpoint and runs the
override;
ping = Noneremoves it. An explicitnameis inherited, a derived one is derived again from the subclass's name. status_code=201sets the success status; return(value, status),(value, headers)or(value, status, headers)to vary it per call while keeping the typed serialization.-> bytesis served asapplication/octet-stream,media_type="image/png"sets another type (both documented in OpenAPI), and a returnedResponsepasses through.- Call
conditional(etag, last_modified=...)before the expensive part of a GET: a matchingIf-None-Match/If-Modified-Sinceends the request with304, otherwise the validators go on the response. @webhook(payload=Model)turns the method into the trigger. Anywhere in your app (inside an app context):
shop.order_created(OrderCreated(order_id=99, total=123.45))
# also accepts a dict or kwargs; payload is validated before sending
# returns the number of deliveries dispatched
Every URL subscribed to shop.order_created receives a POST with the JSON
payload and X-Integration-Event, X-Integration-Delivery-Id (same on
retries) and X-Integration-Timestamp headers. With a secret it is signed:
X-Integration-Signature-V2: sha256=<hmac> over <delivery id>.<timestamp>.<body>
(check with verify_signature(secret, headers, body), which rejects stale
timestamps), plus a body-only X-Integration-Signature (no replay protection). Delivery
runs on a background thread pool with retries (3 attempts, backoff), so
triggering never blocks the caller; each delivery's outcome is kept in a
delivery log (shop.list_deliveries(), flask integration list-deliveries).
Subscriptions can be scoped to a partition (--partition docs) and managed
from Python (shop.subscribe(...), and
flask_integration.subscriptions.update_subscription(app, id, ...) to rotate a
secret or pause). Deliveries can run on the host's queue and HTTP
client: FlaskIntegration(..., deliver=lambda job: queue.enqueue(run_delivery, job), send=my_post). The default sender caps each attempt at the delivery timeout
in total, never reads the response body and does not follow redirects, so a
hostile receiver cannot hold a worker; it will post to internal addresses,
though, so let only operators subscribe unless send is a client with SSRF
guards.
Pass session=db.session to trigger inside the caller's transaction: the push
waits for the commit and is dropped on rollback, and the trigger returns None.
Event log and paging
@event(payload=Model, partition="field") is a webhook that is first written
to a durable, ordered log, so consumers can resume from a cursor instead of
relying on pushes:
class PublishIntegration(FlaskIntegrationBase):
@event(payload=PageChanged, partition="space")
def page_changed(self): ...
@api_endpoint("/spaces/<space>/changes", errors={410: "Cursor too old"})
def changes(self, space: str, q: Annotated[EventQuery, Query]) -> EventPage:
return self.read_events(q.since, partition=space, limit=q.limit)
publish = PublishIntegration()
rec = publish.page_changed(space="docs", slug="home", session=db.session)
db.session.commit() # row commits with the change; push (X-Integration-Event-Id: seq) follows
read_events returns items, next_since and more; a since older than
retention (purge_events(app, timedelta(days=30)) or
flask integration purge-events --older-than-days 30) gets 410 cursor_expired.
latest_event_seq() is where a consumer starts after a full sync.
self.event_stream(since, partition=...) serves the log as Server-Sent Events
(id: = seq, Last-Event-ID resumes, heartbeats, bounded lifetime), from an
endpoint declared with media_type="text/event-stream". On the consumer side,
flask_integration.receiver has iter_event_pages, stream_events,
verify_signature and CursorExpired.
For listings, Paged[T] + PageQuery + paginate(session, stmt, Model.key, cursor, limit)
(or paginate_items for lists) give keyset paging with opaque cursors.
Authentication
By default all endpoints require an API key in X-API-Key (or
Authorization: Bearer …).
Keys are stored as SHA-256 hashes; only a short prefix remains visible.
The OpenAPI document is public by default (INTEGRATION_OPENAPI_REQUIRE_KEY = True
to lock it down).
Whoever authenticated is the principal: mark a parameter
Annotated[ApiKeyIdentity, Principal] to receive it, or call
current_principal(). To use the host app's own tokens or users instead
of the key table, pass an authenticator — a callable returning the principal,
or None for a 401 (raise ApiError(403, …) to forbid):
FlaskIntegration(
app, shop, url_prefix="/api/v1",
authenticate=token_user, # reads flask.request, returns e.g. a User
security_schemes={"BearerAuth": {"type": "http", "scheme": "bearer"}},
)
Request bodies must be application/json (415 otherwise, 400 for invalid
JSON). An authenticator that accepts a session cookie still needs the host's
CSRF protection for endpoints without a body.
CLI
flask integration create-key ci-bot # prints the key once
flask integration list-keys
flask integration revoke-key ci-bot
flask integration add-webhook shop order_created https://receiver/hook --secret s3cret
flask integration list-webhooks # --json --show-secrets to include secrets
flask integration set-webhook 1 --secret n3w --inactive
flask integration remove-webhook 1
flask integration list-deliveries --limit 20
flask integration purge-deliveries --older-than-days 14
flask integration purge-events --older-than-days 30
flask integration list-endpoints # everything declared in code
add-webhook validates the integration and event names against what is
actually declared in code.
Database
The framework needs INTEGRATION_DATABASE_URI (falls back to
SQLALCHEMY_DATABASE_URI) — or the host's own engine,
FlaskIntegration(app, shop, engine=db.engine) (a callable such as
lambda: db.engine is resolved on first use) — and owns five tables:
flask_integration_api_keys, flask_integration_webhooks, the delivery log
flask_integration_deliveries, and for the event log
flask_integration_events and flask_integration_event_marks.
Timestamps are stored as naive UTC and returned timezone-aware.
With live-alembic installed in the host app, the tables are contributed automatically through the entry point declared in this package:
[project.entry-points."live_alembic.models"]
flask_integration = "flask_integration.models"
so a plain livealembic migrate --file migrate_env.py in the host app creates
them. Without live-alembic, create them yourself:
from flask_integration.models import Base
from flask_integration.db import get_engine
Base.metadata.create_all(get_engine(app))
OpenAPI
Each integration serves <url_prefix>/openapi.json. openapi_route="/spec.json"
moves it; openapi_route=None turns it off for a host that serves its own
document, which can then merge the integration in:
from flask_integration import merge_openapi
publish_api = FlaskIntegration(app, PublishIntegration(), url_prefix="/api/v1", openapi_route=None)
merge_openapi(host_spec, publish_api.openapi_spec(server_url="/api/v1"))
server_url (a path prefix of url_prefix) makes the paths relative to the
host's server; conflicting paths, schemas or schemes raise ValueError.
Debug action log
app.config["INTEGRATION_DEBUG_LOG"] = True # log to stderr
app.config["INTEGRATION_DEBUG_LOG_FILE"] = "fi.log" # ... or to a file
Logs every action: key creation/revocation, auth successes and failures,
endpoint calls, validation failures, webhook triggers, and each delivery
attempt with its outcome (logger name: flask_integration.actions). It carries
no keys, secrets, payloads or rejected input values, and webhook URLs only as
scheme, host and port.
Config reference
| Key | Default | Purpose |
|---|---|---|
INTEGRATION_DATABASE_URI |
falls back to SQLALCHEMY_DATABASE_URI |
where keys, webhooks, deliveries and events are stored |
INTEGRATION_DEBUG_LOG |
False |
enable the action debug log |
INTEGRATION_DEBUG_LOG_FILE |
stderr | log destination |
INTEGRATION_SYNC_DELIVERY |
False |
deliver webhooks inline (tests) |
INTEGRATION_DELIVERY_TIMEOUT |
10 |
time limit per delivery attempt, in total (seconds) |
INTEGRATION_DELIVERY_WORKERS |
4 |
delivery threads per app |
INTEGRATION_DELIVERY_RETRIES |
3 |
delivery attempts per URL |
INTEGRATION_OPENAPI_REQUIRE_KEY |
False |
require an API key for /openapi.json |
INTEGRATION_DELIVERY_LOG |
True |
keep the webhook delivery log |
INTEGRATION_SSE_POLL_SECONDS |
2 |
event-stream poll interval |
INTEGRATION_SSE_HEARTBEAT_SECONDS |
15 |
event-stream heartbeat interval |
INTEGRATION_SSE_MAX_SECONDS |
300 |
event-stream lifetime before the client reconnects |
INTEGRATION_SSE_RETRY_MS |
3000 |
reconnect delay suggested to clients |
Example app
See example/app.py for a runnable host app and example/migrate_env.py for
the live-alembic setup.
Metadata
Release files for flask-integration 0.2.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| flask_integration-0.2.2.tar.gz | 252.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| flask_integration-0.2.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 302.5 kB
Release files / flask_integration-0.2.2.tar.gz
| Download URL | flask_integration-0.2.2.tar.gz |
|---|---|
| Size | 252.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b01072ac8c4903239bb68760f54f02ca5575d7313057b8a3e5a38f3c04e57247
|
|
BLAKE2b-256 checksum How to use checksums |
1e0df4c53a30e533e3cf48dbaf00cc0aa4eea0b88302d7fd50b9650436e46b6a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / flask_integration-0.2.2-py3-none-any.whl
| Download URL | flask_integration-0.2.2-py3-none-any.whl |
|---|---|
| Size | 50.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
975c5b478d1921216b59181c54241b5a86536249993d3a55afe2d801e282d689
|
|
BLAKE2b-256 checksum How to use checksums |
522fa372a136a0dc9f1a11c491239b273e333ef005fe638cff18dd4301db22ca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.9.30 {"installer":{"name":"uv","version":"0.9.30","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Debian GNU/Linux","version":"12","id":"bookworm","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for flask-integration 0.2.2
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.