Python SDK for Verstka API v2: open editor sessions, verify callbacks, download and process material content (media + fonts).
Project description
verstka-sdk
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.
Installation
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 integrations
Python 3.10+ is required.
Configuration
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
)
Quickstart
Open an editor session (async)
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.
Reserved metadata keys (editor)
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.
Open an editor session (sync)
from verstka_sdk import VerstkaClient, VerstkaConfig
with VerstkaClient(VerstkaConfig(...)) as client:
url = client.get_editor_url(material_id="42")
Storage adapters
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)
Handling the material callback
The JSON body that Verstka POSTs to your callback_url looks like:
{
"material_id": "42",
"content_url": "https://api.r2.verstka.org/integration/download/<token>",
"metadata": {
"userId": 11,
"AnyOtherKey": "AnyOtherVal",
"user_email": "author@example.com",
"user_ip": "203.0.113.10"
}
}
The HMAC-SHA256 digest is sent in the X-Verstka-Signature HTTP header (same
formula as for session/open), not inside the JSON body.
(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,
signature=request.headers.get("X-Verstka-Signature", ""),
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 from the
X-Verstka-Signatureheader (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,
signature=request.headers.get("X-Verstka-Signature", ""),
storage=storage,
on_finalize=on_content_finalize,
)
Handling the fonts callback
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 from the
X-Verstka-Signatureheader. - 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:
sig = request.headers.get("X-Verstka-Signature", "")
# With on_finalize — records the saved URLs in the database.
result = await client.process_fonts_callback(
callback_data,
signature=sig,
storage=storage,
on_finalize=on_fonts_finalize,
)
# Without on_finalize — fonts are still persisted through storage.
result = await client.process_fonts_callback(
callback_data, signature=sig, 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. |
Access control via on_pre_save hooks
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: extra validation via metadata
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,
signature=request.headers.get("X-Verstka-Signature", ""),
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.
Framework integrations
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": "..."}.
FastAPI
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.
Flask
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
)
)
Django (async views)
# 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",
]
Django REST Framework
# 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",
}
Signatures
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}")). The digest is always sent
in the X-Verstka-Signature HTTP header (including for incoming
callbacks); the JSON body does not carry a signature field.
Errors
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.
Migrating from verstka_api_v2.py
| Old service method | New API |
|---|---|
VerstkaV2APIService().get_editor(...) |
AsyncVerstkaClient.get_editor_url(...) |
process_callback_v2(save_media_fn, finalize_fn) |
process_material_callback(..., signature=header, storage, on_finalize) |
process_site_fonts_callback(save_font_fn) |
process_fonts_callback(..., signature=header, storage, on_finalize) |
verstka_callback_v2_new decorator |
call process_material_callback with X-Verstka-Signature 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": {...}}.
Development
python3.10 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -e '.[dev]'
pytest
ruff check src tests
mypy src
The repository .gitignore ignores both venv/ and .venv/ — use whichever
path you prefer locally.
Changelog
0.1.0
- 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.
License
MIT — see LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file verstka_sdk-0.1.2.tar.gz.
File metadata
- Download URL: verstka_sdk-0.1.2.tar.gz
- Upload date:
- Size: 35.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3401b1706a7eaea2263dddfd8ae028522926a2dbef29ef246c554a9bf80516d2
|
|
| MD5 |
23a2cde1ed57ac04340414795640eac4
|
|
| BLAKE2b-256 |
8e6682dc8afcfcafed4bf2c7fc4ab99995dea9bc4e08af1c598f16372ba0a5e8
|
File details
Details for the file verstka_sdk-0.1.2-py3-none-any.whl.
File metadata
- Download URL: verstka_sdk-0.1.2-py3-none-any.whl
- Upload date:
- Size: 37.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c1c2d3b87bd29c7857d776cd19fd85daa94c65c6680175335714a05359abbea1
|
|
| MD5 |
816f2751d84e000c31defd76745eac89
|
|
| BLAKE2b-256 |
372cba00bd2ae16daa15535a9a37899a047b42e189920d8b81b0d7bd50ebd1fd
|