Python SDK for Verstka API v2. Open editor sessions, verify and process callbacks (material content + site fonts), and plug the result into your web framework of choice.
Features:
- Sync (
VerstkaClient) and async (AsyncVerstkaClient) clients, both onhttpx. - Framework-agnostic core. Pluggable integrations for FastAPI, Flask, Django (async views), and Django REST Framework.
- HMAC-SHA256 signature verification for incoming callbacks.
- Streaming ZIP download with a configurable size cap and path-traversal protection.
- Automatic extraction of
vms_media/*,vms_json.json,vms_html.html, and font bundles (vms_fonts/*,vms_fonts.json,vms_fonts.css). - Automatic
dummy-*replacement in HTML/CSS andclientUrlupdates insidevms_json.assetsand the fonts tree. - Storage-adapter contract (
StorageAdapter/AsyncStorageAdapter) with a reference filesystem implementation; bring your own S3/GCS/CDN backend. - Typed
on_finalizecallback with a full context object (saved URLs, rewrittenvms_json/vms_html, metadata, etc.). - Optional PreSave hooks (
on_pre_save): run extra checks before any ZIP download or storage write — e.g. allow/deny by editor-suppliedmetadata["user_email"]/metadata["user_ip"](reserved keys, refreshed on every save) or by any custom keys you set inmetadatawhen opening the editor session.
pip install verstka-sdk # core only
pip install 'verstka-sdk[fastapi]' # + FastAPI integration
pip install 'verstka-sdk[flask]' # + Flask integration
pip install 'verstka-sdk[django]' # + Django integration
pip install 'verstka-sdk[drf]' # + Django REST Framework integration
pip install 'verstka-sdk[all]' # all integrationsPython 3.10+ is required.
All runtime settings live on a single VerstkaConfig model that you pass to
the client:
from verstka_sdk import VerstkaConfig
config = VerstkaConfig(
api_key="verstka-api-key",
api_secret="verstka-api-secret",
callback_url="https://app.example.com/verstka/callback",
api_url="https://api.r2.verstka.org/integration", # optional, defaults to prod
max_content_size=100 * 1024 * 1024, # 100 MiB, default
request_timeout=60.0,
download_timeout=120.0,
basic_auth_user=None, # optional HTTP basic auth for callbacks
basic_auth_password=None,
debug=False, # include extra info on errors
)from verstka_sdk import AsyncVerstkaClient, VerstkaConfig
async def open_editor(material_id: str, vms_json: dict | None) -> str:
config = VerstkaConfig(...)
async with AsyncVerstkaClient(config) as client:
return await client.get_editor_url(
material_id=material_id,
vms_json=vms_json,
metadata={
"userId": 11,
"userIP": "127.0.0.1",
"AnyOtherKey": "AnyOtherVal",
},
)Both vms_json and metadata accept either a dict or a JSON string.
The Verstka editor reserves user_email and user_ip. On every save
(callback), it sets or overwrites these keys in the metadata object you
receive — values you might pass in get_editor_url for those names are not
preserved across saves. Use your own keys (for example AnyOtherKey, internalUserId,
fonts_callback_allowed) for values your backend must trust; treat
user_email / user_ip in callbacks as editor-supplied identity and
network context for policy checks (e.g. PreSave allowlists).
Fields you add under metadata (other than the reserved names above) are echoed
in save callbacks together with the editor-controlled keys. That lets you
implement PreSave validation in your webhook (see
Access control via on_pre_save hooks)
without trusting client-supplied headers alone — as long as you only set
trusted keys on the server when calling get_editor_url.
from verstka_sdk import VerstkaClient, VerstkaConfig
with VerstkaClient(VerstkaConfig(...)) as client:
url = client.get_editor_url(material_id="42")The SDK never writes to your storage directly — it delegates to an adapter that you provide. Adapters implement one of two protocols:
from pathlib import Path
from collections.abc import Mapping
from typing import Any
class StorageAdapter: # sync contract (used by VerstkaClient)
def save_media(
self, filename: str, temp_path: Path,
material_id: str, metadata: Mapping[str, Any],
) -> str: ...
def save_font_file(
self, filename: str, temp_path: Path,
material_id: str, metadata: Mapping[str, Any],
) -> str: ...
def save_fonts_manifest(
self, filename: str, temp_path: Path,
material_id: str, metadata: Mapping[str, Any],
) -> str: ...AsyncStorageAdapter is the async twin — each method returns an awaitable.
Every call receives the trailing (material_id, metadata) pair so multi-tenant
adapters can route writes by metadata["AnyOtherKey"], metadata["tenant"],
environment, etc.
The SDK ships with filesystem-backed reference implementations:
from verstka_sdk import LocalStorageAdapter, LocalAsyncStorageAdapter
storage = LocalStorageAdapter(
root="/var/www/example.com/public/static/verstka-media",
base_url="https://cdn.example.com",
)
# → media: /var/www/example.com/public/static/verstka-media/materials/<material_id>/<filename>
# → fonts: /var/www/example.com/public/static/verstka-media/fonts/<filename>
# → URL: https://cdn.example.com/materials/<material_id>/<filename>Writing a custom adapter (e.g. S3):
class S3Storage:
def __init__(self, bucket: str, cdn_url: str) -> None:
self.bucket = bucket
self.cdn_url = cdn_url.rstrip("/")
def save_media(self, filename, temp_path, material_id, metadata):
key = f"materials/{material_id}/{filename}"
s3_client.upload_file(str(temp_path), self.bucket, key)
return f"{self.cdn_url}/{key}"
def save_font_file(self, filename, temp_path, material_id, metadata):
key = f"fonts/{filename}"
s3_client.upload_file(str(temp_path), self.bucket, key)
return f"{self.cdn_url}/{key}"
def save_fonts_manifest(self, filename, temp_path, material_id, metadata):
return self.save_font_file(filename, temp_path, material_id, metadata)The payload that Verstka POSTs to your callback_url looks like:
{
"material_id": "42",
"content_url": "https://api.r2.verstka.org/integration/download/<token>",
"signature": "<hmac-sha256>",
"metadata": {
"userId": 11,
"AnyOtherKey": "AnyOtherVal",
"user_email": "author@example.com",
"user_ip": "203.0.113.10"
}
}(user_email and user_ip are written by the editor on each save; your own
keys such as siteId are merged from the session.)
You provide:
- A
StorageAdapter/AsyncStorageAdapter— the SDK callssave_mediafor every file found undervms_media/. - An
on_finalizecallback — invoked once, after all IO, with a typedContentFinalizeContext. - Optionally an
on_pre_savecallback — see Access control viaon_pre_savehooks. Use it for extra validation onctx.metadata(your session keys merged with editor-reserveduser_email/user_ip, which are refreshed on every save) before the content ZIP is downloaded.
from verstka_sdk import (
AsyncVerstkaClient,
ContentFinalizeContext,
ContentFinalizeResult,
)
async def on_content_finalize(ctx: ContentFinalizeContext) -> ContentFinalizeResult:
# ctx.vms_html and ctx.vms_json already have all `dummy-<filename>`
# placeholders replaced with public URLs returned by storage.save_media.
await db.save_material(
material_id=ctx.material_id,
html=ctx.vms_html,
state=ctx.vms_json,
metadata=ctx.metadata,
)
return ContentFinalizeResult(success=True, vms_json=ctx.vms_json)
async with AsyncVerstkaClient(config) as client:
result = await client.process_material_callback(
callback_data,
storage=storage,
on_finalize=on_content_finalize,
)
return result.to_response()
# → {"rc": 1, "rm": "Saved successfully", "data": {"vms_json": {...}}}ContentFinalizeContext exposes:
| Field | Description |
|---|---|
material_id |
Material identifier from the callback payload. |
metadata |
metadata dict from the callback payload (tenant/site routing). |
vms_json |
Rewritten VMS state (assets[*].clientUrl updated). |
vms_html |
Rewritten HTML (dummy-<filename> replaced). |
saved_media_urls |
{filename: public_url} map returned by storage.save_media. |
ContentFinalizeResult(success, vms_json=None) — vms_json is included in
the HTTP response body under data.vms_json if non-None.
The SDK does, in order:
- Verify the HMAC-SHA256 signature (
VerstkaSignatureErroron mismatch). - Optionally run
on_pre_save(ContentPreSaveContext)— validate policy onmetadata(andmaterial_id,content_url) before any download or storage. If it returnsPreSaveDecision(allow=False), the flow stops here withrc=0(see Access control viaon_pre_savehooks). - Stream the content ZIP, refusing to exceed
max_content_size. - Extract
vms_media/*into a temp dir and callstorage.save_mediafor each file. - Replace every
dummy-<filename>invms_htmlwith the returned URL and updatevms_json["assets"][<filename>]["clientUrl"]. - Invoke
on_finalize(ctx)and use itsContentFinalizeResultto shape the HTTP response. - Remove the temp dir, even on errors.
Sync version uses VerstkaClient with plain synchronous callables:
def on_content_finalize(ctx: ContentFinalizeContext) -> ContentFinalizeResult:
db.save_material(ctx.material_id, ctx.vms_html, ctx.vms_json)
return ContentFinalizeResult(success=True, vms_json=ctx.vms_json)
with VerstkaClient(config) as client:
result = client.process_material_callback(
callback_data,
storage=storage,
on_finalize=on_content_finalize,
)Verstka emits a separate site_fonts_updated callback when site-level fonts
change. The payload carries a fonts tree and a content_url pointing at a
ZIP with vms_fonts/* font binaries and the vms_fonts.json / vms_fonts.css
manifests.
The SDK:
- Verifies the signature.
- Optionally runs
on_pre_save(FontsPreSaveContext)— same idea as for material: validate before download/storage; onallow=Falsethe flow stops withrc=0(see Access control viaon_pre_savehooks). - Downloads + extracts the ZIP.
- Calls
storage.save_font_file(filename, temp_path, material_id, metadata)for every binary invms_fonts/. - Rewrites
dummy-<font_id>placeholders insidevms_fonts.cssin memory before persisting it — your remote storage only receives the final CSS once. - Calls
storage.save_fonts_manifest(...)for bothvms_fonts.cssandvms_fonts.jsonwith the same(material_id, metadata)tail. - Fills
clientUrlfields throughout thefontspayload. - Invokes
on_finalize(FontsFinalizeContext)when it is provided and builds the HTTP response.
on_finalize is optional for the fonts flow. When it is omitted the SDK
still persists every font binary and manifest through storage and returns
the default payload to Verstka. This is the right default when your templates
already reference deterministic storage URLs and no extra application state
needs to change on each fonts update.
from verstka_sdk import (
AsyncVerstkaClient,
FontsFinalizeContext,
FontsFinalizeResult,
LocalAsyncStorageAdapter,
)
storage = LocalAsyncStorageAdapter(
root="/var/www/example.com/public/static/verstka-media",
base_url="https://cdn.example.com",
)
async def on_fonts_finalize(ctx: FontsFinalizeContext) -> FontsFinalizeResult:
await db.save_site_fonts(
any_other_key=ctx.metadata.get("AnyOtherKey"),
fonts=ctx.fonts,
css_url=ctx.css_url,
json_url=ctx.json_url,
)
return FontsFinalizeResult(success=True, fonts=ctx.fonts)
async with AsyncVerstkaClient(config) as client:
# With on_finalize — records the saved URLs in the database.
result = await client.process_fonts_callback(
callback_data,
storage=storage,
on_finalize=on_fonts_finalize,
)
# Without on_finalize — fonts are still persisted through storage.
result = await client.process_fonts_callback(callback_data, storage=storage)
return result.to_response()FontsFinalizeContext exposes:
| Field | Description |
|---|---|
material_id |
Identifier from the callback payload (often a post id). |
metadata |
metadata dict from the callback payload. |
fonts |
Fonts tree with clientUrl filled in (binaries + CSS). |
css_url |
Public URL of the saved vms_fonts.css, or None. |
json_url |
Public URL of the saved vms_fonts.json, or None. |
saved_font_urls |
{font_id: url} map returned by storage.save_font_file. |
Every process_*_callback method accepts an optional on_pre_save hook that
runs after signature verification and before any ZIP download or
storage write. It receives a lightweight context (material_id, metadata,
content_url, and for fonts also the declared fonts tree) and returns a
PreSaveDecision.
PreSave is the right place for policy and validation that only needs
identifiers and business context — not the full ZIP. Typical inputs are your
keys from get_editor_url plus editor-reserved user_email and user_ip
(see Reserved metadata keys (editor)); the
latter are overwritten by the editor on every save, so they reflect the
current save context, not values you tried to set at session open. Examples:
| Check | Idea |
|---|---|
metadata["user_email"] |
Editor-supplied on each save — deny if blocked, disposable-domain list, or not in your org’s allowlist. |
metadata["AnyOtherKey"] |
Reject if the key is disabled or not in your allowlist. |
FontsPreSaveContext.fonts |
Inspect the declared font tree (family names, file ids) against rules your backend owns — e.g. blocklist of font ids, or match against metadata keys your server set at session open. |
| Required keys | Return PreSaveDecision(allow=False, reason="...") if a required custom key (e.g. siteId) is missing, or if your policy requires user_email but the editor did not supply it. |
Because PreSave runs before storage.save_*, a rejection does not write
partial files to your bucket or disk.
from verstka_sdk import (
ContentPreSaveContext,
FontsPreSaveContext,
PreSaveDecision,
)
BLACKLIST = {"blocked@example.com"}
def reject_blacklisted(ctx: ContentPreSaveContext) -> PreSaveDecision:
email = ctx.metadata.get("user_email")
if not isinstance(email, str) or "@" not in email:
return PreSaveDecision(allow=False, reason="Invalid user_email in metadata")
if email.lower() in BLACKLIST:
return PreSaveDecision(allow=False, reason="User blacklisted")
return PreSaveDecision(allow=True)
def reject_fonts_unless_flag(ctx: FontsPreSaveContext) -> PreSaveDecision:
# Your server sets this when opening the editor (client never invents it).
if not ctx.metadata.get("fonts_callback_allowed"):
return PreSaveDecision(allow=False, reason="Fonts callback not enabled")
return PreSaveDecision(allow=True)
result = client.process_material_callback(
callback_data,
storage=storage,
on_finalize=on_content_finalize,
on_pre_save=reject_blacklisted,
)When PreSaveDecision(allow=False, reason=...) is returned the SDK:
- skips the ZIP download entirely (no bandwidth is spent on rejected payloads);
- skips every
storage.save_*call and theon_finalizehook; - responds to Verstka with
rc=0and the providedreasonasrm.
The async client expects an async callable with the same signature. A reason
of None falls back to "Operation rejected" in the HTTP body.
All integrations are optional. The SDK's core code raises only its own
VerstkaError subclasses; each integration maps them to JSON responses:
| Exception | HTTP status | code |
|---|---|---|
VerstkaSignatureError |
400 | invalid_signature |
VerstkaCallbackDataError |
400 | invalid_callback_data |
VerstkaVmsJsonError |
400 | invalid_vms_json |
VerstkaMetadataJsonError |
400 | invalid_metadata_json |
VerstkaApiError |
status_code or 502 |
verstka_api_error |
VerstkaError (other) |
500 | verstka_error |
Response body shape: {"error": "<code>", "code": "<code>", "message": "..."}.
from fastapi import FastAPI
from verstka_sdk import AsyncVerstkaClient, LocalAsyncStorageAdapter, VerstkaConfig
from verstka_sdk.integrations.fastapi import (
install_exception_handlers,
build_callback_router,
)
app = FastAPI()
client = AsyncVerstkaClient(VerstkaConfig(...))
storage = LocalAsyncStorageAdapter(root="/srv/media", base_url="https://cdn.example.com")
install_exception_handlers(app)
app.include_router(
build_callback_router(
client,
storage=storage,
on_content_finalize=on_content_finalize,
on_fonts_finalize=on_fonts_finalize, # optional
on_content_pre_save=reject_blacklisted, # optional access-control hook
on_fonts_pre_save=reject_fonts_unless_flag, # optional access-control hook
)
)
# Always registers both POST /verstka/callback and POST /verstka/fonts-callback.from flask import Flask
from verstka_sdk import LocalStorageAdapter, VerstkaClient, VerstkaConfig
from verstka_sdk.integrations.flask import (
register_error_handlers,
build_blueprint,
)
app = Flask(__name__)
client = VerstkaClient(VerstkaConfig(...))
storage = LocalStorageAdapter(root="/srv/media", base_url="https://cdn.example.com")
register_error_handlers(app)
app.register_blueprint(
build_blueprint(
client,
storage=storage,
on_content_finalize=on_content_finalize,
on_fonts_finalize=on_fonts_finalize, # optional
)
)# urls.py
from django.urls import path
from verstka_sdk import AsyncVerstkaClient, LocalAsyncStorageAdapter, VerstkaConfig
from verstka_sdk.integrations.django import build_callback_views
client = AsyncVerstkaClient(VerstkaConfig(...))
storage = LocalAsyncStorageAdapter(root="/srv/media", base_url="https://cdn.example.com")
views = build_callback_views(
client,
storage=storage,
on_content_finalize=on_content_finalize,
on_fonts_finalize=on_fonts_finalize, # optional
)
urlpatterns = [
path("verstka/callback/", views["callback"]),
path("verstka/fonts-callback/", views["fonts_callback"]),
]Optionally install the exception middleware:
# settings.py
MIDDLEWARE = [
...,
"verstka_sdk.integrations.django.VerstkaExceptionMiddleware",
]# urls.py
from django.urls import path
from verstka_sdk import LocalStorageAdapter, VerstkaClient, VerstkaConfig
from verstka_sdk.integrations.drf import build_callback_views
client = VerstkaClient(VerstkaConfig(...))
storage = LocalStorageAdapter(root="/srv/media", base_url="https://cdn.example.com")
views = build_callback_views(
client,
storage=storage,
on_content_finalize=on_content_finalize,
)
urlpatterns = [
path("verstka/callback/", views["callback"].as_view()),
]
# settings.py
REST_FRAMEWORK = {
"EXCEPTION_HANDLER": "verstka_sdk.integrations.drf.verstka_exception_handler",
}If you need to sign or verify manually:
from verstka_sdk import sign_material, verify_signature
sig = sign_material(material_id="42", url=callback_url, secret=secret)
ok = verify_signature("42", content_url, signature, secret)Both outgoing session/open requests and incoming callbacks use the formula
hex(HMAC_SHA256(secret, f"{material_id}:{url}")).
All SDK errors inherit from VerstkaError:
VerstkaSignatureError— signature missing or invalid.VerstkaCallbackDataError— callback payload is malformed.VerstkaApiError— non-2xx response from Verstka API or content endpoint (carriesstatus_code).VerstkaContentTooLargeError— downloaded ZIP exceedsmax_content_size(subclass ofVerstkaApiError).VerstkaVmsJsonError,VerstkaMetadataJsonError— invalid JSON input.
| Old service method | New API |
|---|---|
VerstkaV2APIService().get_editor(...) |
AsyncVerstkaClient.get_editor_url(...) |
process_callback_v2(save_media_fn, finalize_fn) |
process_material_callback(storage, on_finalize) |
process_site_fonts_callback(save_font_fn) |
process_fonts_callback(storage, on_finalize) |
verstka_callback_v2_new decorator |
call process_material_callback from your view |
VerstkaV2APIError |
VerstkaApiError |
VerstkaV2APIWrongCallbackData |
VerstkaSignatureError / VerstkaCallbackDataError |
VerstkaV2MetadataJsonError |
VerstkaMetadataJsonError |
VerstkaV2VmsJsonError |
VerstkaVmsJsonError |
app.config.get_settings() |
VerstkaConfig(...) |
raise HTTPException(...) |
handled by install_exception_handlers(app) |
The callback response shape is identical:
{"rc": 1/0, "rm": "...", "data": {...}}.
python3.10 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
pytest
ruff check src tests
mypy src- Initial release. Extracted from in-house
verstka_api_v2.py. - Sync + async clients on
httpx. StorageAdapter/AsyncStorageAdapterprotocols with referenceLocalStorageAdapter/LocalAsyncStorageAdapterimplementations.- Typed
ContentFinalizeContext/FontsFinalizeContextwithsaved_media_urls/saved_font_urlsfields;*FinalizeResultto shape the outgoing HTTP response. - Optional
on_pre_savehooks (ContentPreSaveContext/FontsPreSaveContext→PreSaveDecision) that gate storage writes based onmaterial_id/metadatabefore any ZIP download. - Optional
on_fonts_finalize: integrations always register/fonts-callback; when the hook is omitted the SDK still persists fonts throughstorageand returns the default payload to Verstka. - Integrations: FastAPI, Flask, Django (async views + middleware), DRF.
MIT — see LICENSE.