Authoring a Send Provider

The send-provider bundle contract, the two-tier provider checklist, capability semantics, and what the host guarantees about your feedback webhook.

A send provider is an emailing provider that is not Owlat's own MTA: an ESP, a relay, an SMTP service. Owlat treats every one of them as a bundle — a send path, a feedback path, a sending-domain identity, capability declarations and a credential form that travel together — and it accepts that bundle at either of two tiers with the same contract.

Provider N+1 is a package. Nothing in routing, dispatch, retries, the deliverability fallback, the ramp controller, the measurement plane or the credentials UI is edited to add one.

Start with the scaffold:

owlat plugins create acme-relay --name @acme/owlat-relay --template send-provider

That emits a complete, composing bundle: all three executable halves, every capability field this tier may declare, a credential form joined to the variables it writes, a host-verifiable webhook contract, and a suite per half. What is left is your vendor's part, marked TODO at the line it belongs on.

The scaffold lands inside the Owlat checkout you run it in — examples/plugins/<id> unless you pass --dir, and never outside the workspace. That is why the emitted package.json is private and its dependencies are workspace:* and catalog: specifiers: the package is wired to that checkout until you move it out. Publishing it is the last step of moving out, and the emitted README lists the four things that step is.

The bundle

HalfWhat it isRequired
Catalog declarationThe sendTransports contribution in your manifest: capabilities, credential form, retry delaysAlways
Send moduleOne network attempt per call, returning a typed outcomeAlways
Feedback webhookParse-only: verified bytes in, feedback facts outOnly if your provider reports delivery events
Sending-domain identityThe provider conversation about a customer's own domainOnly if your provider verifies domains through an API

The two optional halves are not accompanied by a boolean. Declaring a webhook is the catalog's hasProviderFeedback: true for your kind, and declaring a domainIdentity is its domainVerification: 'api'. One fact, stated once: a boolean beside it could only ever disagree with the module that implements it.

Below is the manifest a bundle is built around — the scaffold's src/manifest.ts with its vendor TODOs answered. It grows the file the minimal manifest starts, so it imports only the capability constant it adds; definePlugin is already in scope. The scaffold derives every variable name and header from your plugin id (acme-relayPLUGIN_ACME_RELAY_API_KEY, x-acme-relay-signature), which is what the names here are.

import { PLUGIN_SEND_TRANSPORT_CAPABILITY } from '@owlat/plugin-kit';

export const acmeRelayPlugin = definePlugin({
    id: 'acme-relay',
    version: '1.0.0',
    capabilities: [PLUGIN_SEND_TRANSPORT_CAPABILITY],
    // Deployment-wide: the plugin is off until both are set. The signing secret
    // belongs HERE and not on the transport — without it the feedback route can
    // verify nothing and answers every delivery 503.
    flag: {
        default: false,
        requiredEnvVars: ['ACME_RELAY_ENABLED', 'PLUGIN_ACME_RELAY_WEBHOOK_SECRET'],
    },
    contributes: {
        sendTransports: [
            {
                id: 'relay',
                label: 'Acme Relay',
                module: { exportPath: './convex/transport' },
                retryDelays: [1_000, 5_000],
                // THIS TRANSPORT's own configuration: resolved per named instance
                // (`PLUGIN_ACME_RELAY_API_KEY__EU` for `plugin.acme-relay.relay#eu`) and
                // handed to `send` keyed by the base name.
                requiredEnvVars: ['PLUGIN_ACME_RELAY_API_KEY'],
                optionalEnvVars: ['PLUGIN_ACME_RELAY_REGION'],
                credentialFields: [
                    {
                        kind: 'secret',
                        key: 'apiKey',
                        label: 'API key',
                        required: true,
                        envVar: 'PLUGIN_ACME_RELAY_API_KEY',
                    },
                    {
                        kind: 'select',
                        key: 'region',
                        label: 'Sending region',
                        options: [
                            { value: 'eu', label: 'Europe' },
                            { value: 'us', label: 'United States' },
                        ],
                        default: 'eu',
                        envVar: 'PLUGIN_ACME_RELAY_REGION',
                    },
                ],
                // `no` is the only value this tier may declare.
                supportsCustomReturnPath: 'no',
                messageIdSource: 'provider',
                deduplicatesOnIdempotencyKey: false,
                // Declaring a webhook IS `hasProviderFeedback: true` for this kind.
                webhook: {
                    module: { exportPath: './convex/webhook' },
                    signature: {
                        header: 'x-acme-relay-signature',
                        algorithm: 'hmac-sha256',
                        encoding: 'hex',
                        secretEnvVar: 'PLUGIN_ACME_RELAY_WEBHOOK_SECRET',
                        // REQUIRED: without replay provisions a captured request verifies
                        // forever. The host signs `<timestamp>.<rawBody>`.
                        replay: { timestampHeader: 'x-acme-relay-timestamp', toleranceSeconds: 300 },
                    },
                },
                // Declaring an identity IS `domainVerification: 'api'` for this kind.
                domainIdentity: { module: { exportPath: './convex/domainIdentity' } },
            },
        ],
    },
});

Everything executable lives at a module.exportPath your package exports, and each one is imported as that module's default export — as is the manifest, at your package root. Codegen verifies each path without running it; the host imports the module only when a send, a delivery or an identity check actually needs it. Export by name as well if you like, but a module with only a named export composes into a registry entry whose module is undefined, and the first send is the thing that finds out. The scaffold emits both.

The provider checklist, at both tiers

The same six steps, whichever tier you are on. Nothing else — enforced by the conformance suites, which iterate catalog kinds rather than a list, and by the lint:providers ratchet, which fails any comparison against a provider-kind literal outside an adapter folder.

The core column below is a summary. Send Providers → the provider-N+1 checklist is the canonical core-tier list, with the exact file paths, the compile-time guards behind each step and the one optional step this table does not carry.

#Core kindPlugin kind
1Catalog entry in packages/shared/src/sendProviderCatalog.ts (kind, capabilities, credential fields — the union widens itself; the field vocabulary lives in sendProviderCatalogTypes.ts and the form descriptors in sendProviderCredentialFields.ts)sendTransports contribution in the package manifest, with the same capability fields
2Env keys in lib/env.ts and the .env examplesEnv keys declared in the contribution's requiredEnvVars (the host asserts presence; lint:env untouched)
3Adapter folder lib/sendProviders/<kind>/ (a completeness guard forces it)Send module export, provenance-verified and pinned by a mapped-type guard in the generated registry
4Webhook adapter in the feedback registry, if the provider reports feedbackwebhook module export, if the provider reports feedback
5Domain-identity provider in the registry, if verification is apidomainIdentity module export, if the provider verifies domains
6Docs page sectionPackage README; listed in plugins.config.ts

No routing, dispatch, ramp, measurement or setup-wizard edit is required at either tier. If you find yourself making one, the capability you need is missing from the contract — that is a change to the contract, not to your bundle.

The one thing a core kind may optionally add is UI wording: apps/web carries Partial override maps for a transport's label, its DNS guidance paragraph and its picker copy (step 7 on the canonical list). A kind with no row renders from its catalog entry — nothing breaks and nothing is blank — so write one only when this surface should word your provider differently. A plugin kind has no equivalent step at all: those maps are keyed by core kind, and a bundle's own label and credentialFields are what describe it.

Capability semantics

Every field below is optional and every one has the same fail-closed default a core entry that omitted it would get, so a manifest written against an older contract composes exactly as it did.

FieldWhat a bundled transport may declareWhat it promises
supportsCustomReturnPathnoWhether Owlat may choose the envelope sender. yes and probe need a VERP local part signed with a deployment secret a bundled module is never handed, so no is the only value this tier has — and the honest one.
messageIdSourceprovider, composedWhere the id your send returns comes from: your own API (provider), or the RFC 5322 Message-ID Owlat already minted and you echoed back (composed). Every feedback event is joined on it.
deduplicatesOnIdempotencyKeytrue, falseWhether the same request may be sent twice under one key without delivering twice. Declaring true requires buildSystemMailExtras carrying that key into your request; the host refuses the composition otherwise, because a claim without the wiring turns a double delivery into a "safe" retry of a password reset.
hasProviderFeedbackderivedTrue exactly when the contribution declares a webhook.
domainVerificationderivedapi exactly when the contribution declares a domainIdentity, none otherwise.
acceptanceSemanticsnot declarableCustody of an in-flight message. Only Owlat's own MTA declares it; a third party's timeout is always ambiguous.
tagsFeedbackProvenancenot declarableSays our own MTA stamped the report on its way out of our own infrastructure. Never true of a third party.
setupProbenot declarableNames an exported validator in host code, which a manifest cannot add to.

Configuration and named instances

requiredEnvVars on the contribution is what one instance of your transport needs. Declaring it is what makes named instances possible: a deployment can run plugin.acme-relay.relay#eu reading PLUGIN_ACME_RELAY_API_KEY__EU, and the host hands your send exactly that instance's values, keyed by the base name your manifest wrote. Read them from the config argument — a module that reads process.env resolves the deployment-default instance's credentials whichever id the send was addressed to.

Names are held to one rule: PLUGIN_-prefixed, uppercase, no __ (which is the instance separator) and no trailing _. The plugin's own flag.requiredEnvVars is a different scope — a deployment-wide switch read unsuffixed — and the two lists may not overlap.

optionalEnvVars is refused without at least one required variable: a transport whose whole configuration is optional has no per-instance credential to resolve, so every named instance of it would be graded against an empty requirement list.

The credential form

credentialFields are typed descriptors in the same vocabulary the platform's settingsSchema uses — string, secret, number, boolean, select — so one renderer draws your credential the way it draws a core provider's. They are descriptive only: nothing there decides what a send reads. Every field's envVar must be a variable this transport declared, matched to the field's own required, which is what keeps a rendered form from asking for a variable no send reads or omitting one that gates the transport.

Webhook security expectations

Your feedback module parses. It never decides whether a request is authentic. By the time it runs, the host has already:

  1. spent a per-plugin rate-limit token — the one write an unproven caller causes, and first because a limiter that does not record cannot limit;
  2. resolved the route by plugin id (POST /webhooks/plugin/<pluginId>), answering 404 for an unknown one before a byte of the body is read;
  3. bounded the body's size;
  4. recomputed your declared HMAC over <timestamp>.<rawBody> in constant time and compared it against the header you named;
  5. refused a timestamp further from now than your declared tolerance;
  6. rechecked the operator's capability grant under its own audited operation;
  7. refused a delivery whose signature digest it has already accepted.

Contribution Reference → Feedback webhook is the normative list of what the webhook block must carry — the fields, the tolerance ceiling, which of the two variable scopes the secret belongs in, and how many webhooks a plugin may declare. It is the page to write your manifest against; this section is why each of those rules exists and what it costs to get wrong:

  • signature is required. A webhook without one fails manifest validation. This endpoint is unauthenticated and internet-facing by design; an unverified one would be an open write path into the delivery record.
  • replay provisions are required too. An HMAC over the body alone proves origin and nothing else — the same captured bytes verify forever. Binding a timestamp into the signed string and bounding its age is what makes a delivery happen once. The ceiling on that window is the kit's, not yours; pick the smallest one your provider's retries tolerate, well under it.
  • The signing secret gates the plugin, not the transport. Without it the route can verify nothing and answers every delivery 503 — and a run of non-2xx is what makes a provider deactivate your endpoint — so the plugin must not be enableable without it. That is why it is declared in the scope a deployment reads unsuffixed rather than beside your per-instance credential.
  • The route surface is keyed by plugin id, so a second webhook in the same plugin could never be addressed, and a manifest declaring one is refused rather than silently ignored.
  • Raw payloads are not retained unless you ask. A third party's payload may carry recipient content this deployment never asked to keep, so retention is your explicit decision rather than the pipeline's default.

Inside the module: return the empty array for a console verification ping and for event kinds Owlat does not consume — a 400 would make your provider redeliver them forever — and throw on a body you cannot read. Read an event's timestamp only for the kinds you do consume: an engagement event you ignore may omit the field entirely, and validating it above your kind switch takes the whole batch down with it. Import no Node builtins: the module is loaded by the HTTP router.

Sending-domain identity

Your identity module reports observations, never a verdict: whether the provider has confirmed ownership, its verdicts on the published SPF and DKIM records, the DKIM selectors it signs under and the SPF mechanisms it needs authorised. The host derives the status from those, so "verified" means the same thing at every relay tier and a module cannot report a domain verified while telling us its DKIM is invalid. The freshness bound on that proof is a host constant, not a manifest field.

Distinguish the three outcomes, because the host's write rules differ for each: ok is the only one that refreshes the proof's age, auth_failed condemns a credential without overwriting the DNS verdicts already stored, and unavailable — which a thrown error is also read as — changes nothing but the retry.

Return your DKIM selectors whenever you know them. An empty list means "we cannot describe this domain's signing identity", which the dual-transport alignment pre-flight reads as a hold on the ramp rather than as an opened gate.

What the scaffold gives you

Every template writes the same three-file package skeleton — package.json (private, with the export map your manifest's module paths are checked against), tsconfig.json and vitest.config.ts. On top of that, owlat plugins create <id> --template send-provider emits:

FileWhat it holds
README.mdWhat is done, what is left, the environment variables an operator sets, and how to move the package out of the checkout and publish it
src/index.tsThe package's root export: the manifest, and nothing else
src/manifest.tsThe data-only declaration: capabilities, credential form, signature contract
src/envNames.tsEvery environment variable name, declared once and read by both the manifest and the modules
src/convex/transport.tsThe send path: one attempt, credentials from config, statuses mapped onto the host's retry vocabulary
src/convex/webhook.tsThe feedback path: parse only, isolate-safe
src/convex/domainIdentity.tsThe sending-domain identity: observations only, three distinguishable outcomes
src/__tests__/manifest.test.tsThe manifest validates and keeps the two variable scopes apart
src/__tests__/transport.test.tsCredentials come from the instance configuration; the status mapping holds
src/__tests__/webhook.test.tsThe four feedback facts, the acknowledged ping, the unreadable body
src/__tests__/domainIdentity.test.tsObservations, and a rejected credential distinguished from an outage

The emitted bundle is not a sketch: it composes, and Owlat's own conformance gate drives the generator's output — unedited — through the shipped routing, dispatch, feedback and identity modules on every CI run.

Where to go next