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
| Half | What it is | Required |
|---|---|---|
| Catalog declaration | The sendTransports contribution in your manifest: capabilities, credential form, retry delays | Always |
| Send module | One network attempt per call, returning a typed outcome | Always |
| Feedback webhook | Parse-only: verified bytes in, feedback facts out | Only if your provider reports delivery events |
| Sending-domain identity | The provider conversation about a customer's own domain | Only 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-relay ⇒ PLUGIN_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 kind | Plugin kind |
|---|---|---|
| 1 | Catalog 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 |
| 2 | Env keys in lib/env.ts and the .env examples | Env keys declared in the contribution's requiredEnvVars (the host asserts presence; lint:env untouched) |
| 3 | Adapter 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 |
| 4 | Webhook adapter in the feedback registry, if the provider reports feedback | webhook module export, if the provider reports feedback |
| 5 | Domain-identity provider in the registry, if verification is api | domainIdentity module export, if the provider verifies domains |
| 6 | Docs page section | Package 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.
| Field | What a bundled transport may declare | What it promises |
|---|---|---|
supportsCustomReturnPath | no | Whether 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. |
messageIdSource | provider, composed | Where 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. |
deduplicatesOnIdempotencyKey | true, false | Whether 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. |
hasProviderFeedback | derived | True exactly when the contribution declares a webhook. |
domainVerification | derived | api exactly when the contribution declares a domainIdentity, none otherwise. |
acceptanceSemantics | not declarable | Custody of an in-flight message. Only Owlat's own MTA declares it; a third party's timeout is always ambiguous. |
tagsFeedbackProvenance | not declarable | Says our own MTA stamped the report on its way out of our own infrastructure. Never true of a third party. |
setupProbe | not declarable | Names 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:
- 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;
- resolved the route by plugin id (
POST /webhooks/plugin/<pluginId>), answering 404 for an unknown one before a byte of the body is read; - bounded the body's size;
- recomputed your declared HMAC over
<timestamp>.<rawBody>in constant time and compared it against the header you named; - refused a timestamp further from now than your declared tolerance;
- rechecked the operator's capability grant under its own audited operation;
- 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:
signatureis 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.replayprovisions 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:
| File | What it holds |
|---|---|
README.md | What 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.ts | The package's root export: the manifest, and nothing else |
src/manifest.ts | The data-only declaration: capabilities, credential form, signature contract |
src/envNames.ts | Every environment variable name, declared once and read by both the manifest and the modules |
src/convex/transport.ts | The send path: one attempt, credentials from config, statuses mapped onto the host's retry vocabulary |
src/convex/webhook.ts | The feedback path: parse only, isolate-safe |
src/convex/domainIdentity.ts | The sending-domain identity: observations only, three distinguishable outcomes |
src/__tests__/manifest.test.ts | The manifest validates and keeps the two variable scopes apart |
src/__tests__/transport.test.ts | Credentials come from the instance configuration; the status mapping holds |
src/__tests__/webhook.test.ts | The four feedback facts, the acknowledged ping, the unreadable body |
src/__tests__/domainIdentity.test.ts | Observations, 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
- Contribution Reference — the
sendTransportsbucket beside every other bucket, with the module contracts in full. - Capabilities & Trust — what
send:transportgrants and how an operator revokes it. - The owlat plugins CLI —
create,add,remove,codegen. - Send Providers — the core tier's catalog, and the per-provider capability table.