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
connectendpoint. 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
ActionContextcarrying the organization id, the decrypted credentials, the settings, and a readyhttpxclient with a request timeout applied. Actions never reach for a global client. - Call logging. Every dispatch records an
IntegrationCallLogrow — 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.
| Slug | Actions | What it does |
|---|---|---|
| slack | post_message | Post messages to a channel via an incoming webhook URL |
| stripe | list_customers | Read Stripe customers; also parses charge / subscription webhooks |
| mailgun | send | Send transactional email via Mailgun's HTTP API |
| openai | generate_text, generate_image, embed | Text 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).
# 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:
# 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 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.
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.
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.
# 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.callstep 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.