Skip to main content

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 on httpx.
  • 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 and clientUrl updates inside vms_json.assets and the fonts tree.
  • Storage-adapter contract (StorageAdapter / AsyncStorageAdapter) with a reference filesystem implementation; bring your own S3/GCS/CDN backend.
  • Typed on_finalize callback with a full context object (saved URLs, rewritten vms_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-supplied metadata["user_email"] / metadata["user_ip"] (reserved keys, refreshed on every save) or by any custom keys you set in metadata when 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 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 calls save_media for every file found under vms_media/.
  • An on_finalize callback — invoked once, after all IO, with a typed ContentFinalizeContext.
  • Optionally an on_pre_save callback — see Access control via on_pre_save hooks. Use it for extra validation on ctx.metadata (your session keys merged with editor-reserved user_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:

  1. Verify the HMAC-SHA256 signature (VerstkaSignatureError on mismatch).
  2. Optionally run on_pre_save(ContentPreSaveContext) — validate policy on metadata (and material_id, content_url) before any download or storage. If it returns PreSaveDecision(allow=False), the flow stops here with rc=0 (see Access control via on_pre_save hooks).
  3. Stream the content ZIP, refusing to exceed max_content_size.
  4. Extract vms_media/* into a temp dir and call storage.save_media for each file.
  5. Replace every dummy-<filename> in vms_html with the returned URL and update vms_json["assets"][<filename>]["clientUrl"].
  6. Invoke on_finalize(ctx) and use its ContentFinalizeResult to shape the HTTP response.
  7. 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,
    )

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:

  1. Verifies the signature.
  2. Optionally runs on_pre_save(FontsPreSaveContext) — same idea as for material: validate before download/storage; on allow=False the flow stops with rc=0 (see Access control via on_pre_save hooks).
  3. Downloads + extracts the ZIP.
  4. Calls storage.save_font_file(filename, temp_path, material_id, metadata) for every binary in vms_fonts/.
  5. Rewrites dummy-<font_id> placeholders inside vms_fonts.css in memory before persisting it — your remote storage only receives the final CSS once.
  6. Calls storage.save_fonts_manifest(...) for both vms_fonts.css and vms_fonts.json with the same (material_id, metadata) tail.
  7. Fills clientUrl fields throughout the fonts payload.
  8. 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.

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,
    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 the on_finalize hook;
  • responds to Verstka with rc=0 and the provided reason as rm.

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}")).

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 (carries status_code).
  • VerstkaContentTooLargeError — downloaded ZIP exceeds max_content_size (subclass of VerstkaApiError).
  • 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(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": {...}}.

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 / AsyncStorageAdapter protocols with reference LocalStorageAdapter / LocalAsyncStorageAdapter implementations.
  • Typed ContentFinalizeContext / FontsFinalizeContext with saved_media_urls / saved_font_urls fields; *FinalizeResult to shape the outgoing HTTP response.
  • Optional on_pre_save hooks (ContentPreSaveContext / FontsPreSaveContextPreSaveDecision) that gate storage writes based on material_id/metadata before any ZIP download.
  • Optional on_fonts_finalize: integrations always register /fonts-callback; when the hook is omitted the SDK still persists fonts through storage and 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

verstka_sdk-0.1.1.tar.gz (34.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

verstka_sdk-0.1.1-py3-none-any.whl (36.4 kB view details)

Uploaded Python 3

File details

Details for the file verstka_sdk-0.1.1.tar.gz.

File metadata

  • Download URL: verstka_sdk-0.1.1.tar.gz
  • Upload date:
  • Size: 34.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for verstka_sdk-0.1.1.tar.gz
Algorithm Hash digest
SHA256 8c9f55533f5c8e97bb10fc22a98b331db290650316e6c9cd74d84387d3cc2449
MD5 f6d2349232f7803f3f11db3985a77e17
BLAKE2b-256 bbb47bd99c2c43cb02b642d8577c93e2f99f478bed267b75e8d52be53fa24314

See more details on using hashes here.

File details

Details for the file verstka_sdk-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: verstka_sdk-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 36.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for verstka_sdk-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 a5ff39e943e0d7ebc945b269705763f7b945c30806dc1da00d45ac2620b1b110
MD5 6945e414864613dc8a1d2debd36a0bf8
BLAKE2b-256 09c6bda7db58a8636ff178d0fb81d5d5f7f3faa208d958fb93e14ae0f295b57f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page