All docs

Integrations

Talk to the rest of your stack

polimorf reaches the outside world through a connector gateway: one place that holds credentials, retries failures, verifies inbound signatures, and logs every call. First-party connectors ship in the box; writing your own is a small Python class.

The gateway

Every outbound call and every inbound webhook passes through a single gateway. A connector never holds its own credentials or makes raw network calls on your behalf; it declares actions, and the gateway runs them with the right context. That gives you four things for free, the same way for every connector:

  • Credentials at rest. You enrol an organization once, sending credentials to the connect endpoint. They are stored on the enrolment record and never echoed back in any list response — only their presence is reported.
  • A per-call HTTP client. The gateway hands each action an ActionContext carrying the organization id, the decrypted credentials, the settings, and a ready httpx client with a request timeout applied. Actions never reach for a global client.
  • Call logging. Every dispatch records an IntegrationCallLog row — action, upstream status code, success flag, duration in milliseconds, and any error — committed whether the call succeeded or threw.
  • Honest success. An upstream 4xx or 5xx is treated as a failed call, not a silent success, and surfaces as an error to the caller.

On retries

The gateway is built around a retry-with-backoff model for transient failures. Treat your actions as idempotent where the upstream API allows it, so a retried call is safe.

First-party connectors

Four connectors ship in the box. Each declares a JSON-Schema config for its credentials and a small set of named actions. Connect an organization, then invoke an action by name through the gateway.

SlugActionsWhat it does
slackpost_messagePost messages to a channel via an incoming webhook URL
stripelist_customersRead Stripe customers; also parses charge / subscription webhooks
mailgunsendSend transactional email via Mailgun's HTTP API
openaigenerate_text, generate_image, embedText completions, image generation and vector embeddings

The action names above are the literal values you pass as action when you call a connector — they are declared with the @action decorator in each connector module. Connectors are security-conscious: the Slack connector, for instance, refuses any webhook URL that is not https on hooks.slack.com and resolves the host to reject anything pointing at a private address, closing off the connector as an SSRF primitive.

Enrol and call

Connecting an organization is one POST. Send the credentials your connector's config_schema requires; re-connecting merges new values rather than wiping server-generated ones (such as a webhook_secret).

POST /admin/v1/integrations/{slug}/connect
# Enrol an organization in a connector. Credentials are stored
# encrypted at rest; you send them once.
curl -X POST https://api.polimorf.com/admin/v1/integrations/slack/connect \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{
        "credentials": { "webhook_url": "https://hooks.slack.com/services/T000/B000/XXXX" },
        "settings": {}
      }'

Once enrolled, invoke any declared action by name through the gateway:

POST /admin/v1/integrations/{slug}/call
# Invoke a declared action through the gateway. The gateway looks up
# the enrolment, decrypts the credentials and dispatches the handler.
curl -X POST https://api.polimorf.com/admin/v1/integrations/stripe/call \
  -H 'Authorization: Bearer <access_token>' \
  -H 'Content-Type: application/json' \
  -d '{ "action": "list_customers", "params": { "limit": 5 } }'

The catalog of available connectors is at GET /admin/v1/integrations/catalog; what a given organization has enrolled is at GET /admin/v1/integrations/installed. Removing a connector is a DELETE /admin/v1/integrations/{slug}. All of these require a developer principal.

Inbound webhooks

Connectors can receive as well as send. Each enrolment gets a secret inbound token that forms a public webhook URL. When a provider posts to it, the gateway looks up the enrolment by token, runs the connector's event parser to verify the signature, and logs the raw body with sensitive headers redacted.

inbound webhook
# Inbound webhooks arrive on a public, per-enrolment URL. The trailing
# token identifies the organization; the body is verified before anything
# downstream sees it.
POST https://api.polimorf.com/in/stripe/<inbound_token>

# A verified event is published on the bus as:
#   integration.stripe.<event_type>
# e.g. integration.stripe.charge.succeeded

Only a verified payload goes any further. A valid event is normalized into a NormalizedEvent (a type plus a data bag) and published on the event bus under integration.<slug>.<type> — for example integration.stripe.charge.succeeded. If the signature does not check out, nothing is published and the endpoint reports the failure.

Verification is the parser's job

The gateway never trusts an inbound body until a parser has accepted it. Stripe's parser, for example, rejects a stale signed timestamp (a replay) before it even computes the HMAC, and compares signatures in constant time.

Write a connector

A connector is a Python class that subclasses Connector and decorates handler methods with @action(name). The gateway discovers connectors by walking the builtin directory and discovers actions by reflection — there is no registry to edit. Set the class-level metadata (slug, name, config_schema) and write async handlers that take an ActionContext and a params dict, and return a dict whose _status the gateway reads to decide success.

connector.py
from typing import Any

from polimorf.integrations.base import ActionContext, Connector, action


class AcmeConnector(Connector):
    slug = "acme"
    name = "Acme"
    description = "Push records into Acme via its HTTP API."
    icon = "lucide:box"
    auth_kind = "api_key"
    config_schema = {
        "type": "object",
        "properties": {"api_key": {"type": "string"}},
        "required": ["api_key"],
    }

    @staticmethod
    @action("create_record")
    async def create_record(ctx: ActionContext, params: dict[str, Any]) -> dict[str, Any]:
        key = ctx.credentials.get("api_key")
        if not key:
            return {"_status": 400, "error": "api_key not configured"}
        res = await ctx.http.post(
            "https://api.acme.example/v1/records",
            headers={"Authorization": f"Bearer {key}"},
            json=params,
        )
        return {"_status": res.status_code, "ok": res.status_code < 400}

Each action returns a plain dict. Include _status set to the upstream HTTP status so the gateway can log it and judge success; everything else in the dict is passed back to the caller untouched.

Parse inbound events

To receive webhooks, add a method decorated with @event_parser. It is handed the raw request body, the (lower-cased) headers, and the enrolment's stored secret, and must either return a NormalizedEvent or raise SignatureError. Raising SignatureError is how you tell the gateway the request is not authentic — it will record the attempt as unverified and publish nothing.

event parser
import hashlib, hmac, json

from polimorf.integrations.base import NormalizedEvent, SignatureError, event_parser


@staticmethod
@event_parser
def parse_webhook(raw: bytes, headers: dict[str, str], secret: str) -> NormalizedEvent:
    if not secret:
        raise SignatureError("webhook_secret missing")
    provided = headers.get("x-acme-signature", "")
    expected = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
    # Constant-time compare, then normalize into a platform event.
    if not hmac.compare_digest(expected, provided):
        raise SignatureError("signature mismatch")
    body = json.loads(raw or b"{}")
    return NormalizedEvent(type=body.get("type", "unknown"), data=body.get("data", {}))

Always verify before you trust: compute the expected signature from the raw bytes and compare it in constant time with hmac.compare_digest. The secret comes from the enrolment's webhook_secret credential, so each organization gets its own.

Calling connectors from workflows

You rarely call the gateway by hand. The usual path is a workflow: the integration.call step names a connector and an action and routes through the exact same gateway — same credentials, same logging, same failure handling.

workflow step
# Inside a workflow, the "integration.call" step routes through the same
# gateway — same credentials, same logging, same retries.
{
  "type": "integration.call",
  "config": {
    "connector": "slack",
    "action": "post_message",
    "params": { "text": "New order received" }
  }
}

Because inbound webhooks are published on the bus as integration.<slug>.<type>, workflows can also be triggered by them — a provider event arrives, the gateway verifies and publishes it, and a workflow subscribed to that prefix runs. Some steps wrap a specific connector for convenience (the email step dispatches through the gateway, and an ai.generate step wraps OpenAI's generate_text), but they all bottom out in call_action.

Where to next

  • Workflows — wire the integration.call step into automation, or trigger workflows from inbound events.
  • API reference — auth, organization scoping, and the full request shapes for the integration endpoints.
  • Security — how credentials, isolation, and signature verification fit the wider model.