Write a community adapter
In this tutorial you use ReputeCast’s template to build email provider adapter code for a sending provider that has no built-in adapter, run the contract suite against it, then install it on a test instance and check that ReputeCast can call it. It takes about 30 minutes.
A community adapter is a small process that speaks the adapter protocol: ReputeCast sends it signed JSON requests (send these messages, verify this webhook), and it calls the provider’s API. The template is a working adapter for Example ESP, a fictional provider with a typical API, written with the TypeScript SDK @reputecast/adapter-sdk.
Before you begin
Section titled “Before you begin”- Node.js 24.11 or later, pnpm and Git.
- A test ReputeCast instance on the same machine, where you can run the
reputecastCLI. An installed adapter applies to every workspace on the instance, so don’t try this on a production instance. providers.allow_private_networks: trueon that instance, for the server and for the CLI (REPUTECAST_PROVIDERS__ALLOW_PRIVATE_NETWORKS=true). The adapter listens on127.0.0.1, and ReputeCast refuses loopback addresses without it.
Build and install the adapter
Section titled “Build and install the adapter”-
Copy the template out of the ReputeCast repository:
Terminal git clone https://github.com/hostrepute/reputecast.gitcp -r reputecast/sdk/adapter-template my-esp-adaptercd my-esp-adapterDirectorymy-esp-adapter/
Directorysrc/
- adapter.ts the adapter:
defineAdapter({ id, capabilities, terms, send, … }) - main.ts starts the protocol server
- adapter.ts the adapter:
Directorytest/
- adapter.test.ts unit tests
- contract.test.ts
- fake-api.ts a fake of Example ESP’s API
Directoryfixtures/
- webhooks.json recorded webhooks and the events they must produce
- manifest.json what ReputeCast reads at install time
- reputecast.contract.ts the contract suite’s setup
- .env.example
- Dockerfile
- package.json
-
Install the dependencies:
Terminal pnpm installThis installs
@reputecast/adapter-sdk(Node 24, TypeScript 6, no runtime dependencies) from npm. -
Run the tests:
Terminal pnpm testpnpm testruns the unit tests with Vitest, then the contract suite (reputecast-adapter test). The contract suite starts the adapter, calls it over the protocol against the fake API intest/fake-api.tsand runs the same scenarios ReputeCast’s Go suite runs against built-in adapters:Output Contract suite for example-esp (adapter protocol 1)✓ Registration (40 ms)✓ Health (90 ms)✓ Authentication (56 ms)✓ Send (29 ms)✓ SendRejected (17 ms)✓ SendOutage (17 ms)✓ SendAuth (33 ms)✓ SendIdempotent (36 ms)✓ Verify (81 ms)✓ Quota (8 ms)✓ SuppressionsPull (26 ms)✓ Webhook/Delivered (54 ms)✓ Webhook/HardBounce (63 ms)✓ Webhook/SoftBounce (42 ms)✓ Webhook/Batch (24 ms)15 passed, 0 failed, 0 skippedThe suite checks the manifest against the code, signed health and authentication, a send, a permanent rejection (the fake refuses
rejected@contract.test), an outage, rejected credentials, an idempotent retry, and each recorded webhook: valid, already verified and tampered with. -
Read how the adapter is declared.
src/adapter.tsmaps Example ESP’s API:src/adapter.ts (excerpt) export default defineAdapter<ExampleEvent>({id: 'example-esp',capabilities: { webhooks: true, batch: 100, suppressionSync: 'pull', quotaDiscovery: true },terms: { bulkMarketing: 'allowed' },http: {name: 'Example ESP',baseUrl: () => process.env['EXAMPLE_ESP_API_URL'] ?? API_URL,headers: (ctx) => ({ authorization: `Bearer ${String(ctx.credentials['api_key'] ?? '')}` }),},async send(msg, ctx) {const r = await ctx.http.post<{ id: string }>('/v1/messages', toPayload(msg))return { providerMessageId: r.data.id }},verifyWebhook(req, ctx) {/* checks X-Example-Signature with verifyTimestampedHmac */},normalizeEvent(raw) {/* maps one Example ESP event to a ReputeCast event */},})Each declared capability needs its hook (
webhooksneedsverifyWebhookandnormalizeEvent,quotaDiscoveryneedsdiscoverQuota, apullsync needssyncSuppressions), anddefineAdapterstops the process at start when one is missing.manifest.jsonmust declare the same id, capabilities and terms; the server refuses to start when they differ.ctx.httpreaches only the hosts in the manifest’spermissions.network, and maps the provider’s answers to protocol errors: 401 and 403 becomeprovider_auth, 429provider_throttled, 5xx and timeoutsprovider_unavailable, and any other 4xx a refusal of that one message. -
On the machine that runs ReputeCast, install the adapter from its manifest and give the address the process will listen on:
Terminal reputecast adapter install ./manifest.json --url http://127.0.0.1:4600Output Installed Example ESP 0.1.0 as community:example-esp.Signing secret (shown once):rcas_…Set it on the adapter process as REPUTECAST_ADAPTER_SECRET.Then check it: reputecast adapter check example-espCopy the signing secret now: ReputeCast stores it sealed and never shows it again. If you lose it,
reputecast adapter secret example-espissues a new one. -
Give the adapter its secret and start it:
Terminal cp .env.example .envSet
REPUTECAST_ADAPTER_SECRETin.envto the secret from step 5 (PORT=4600andHOST=127.0.0.1are already there), then start the process:Terminal pnpm devOutput {"time":"2026-10-22T12:20:56.017Z","level":"info","msg":"adapter listening","adapter":"example-esp","url":"http://127.0.0.1:4600","protocol":1}pnpm devrestarts the process when the code changes. Without a secret it doesn’t start: it asks you to setREPUTECAST_ADAPTER_SECRET.GET /healthzanswers{"ok":true}for container health checks, and every other request must be signed by ReputeCast:Terminal curl -s -X POST http://127.0.0.1:4600/v1/health -d '{}'Output {"protocol":1,"idempotencyKey":"","error":{"code":"unauthenticated","message":"Refused: the request is not signed, or its timestamp is more than 5 minutes off."}} -
Check that ReputeCast reaches the adapter with its secret:
Terminal reputecast adapter check example-espOutput community:example-esp: Answered as example-esp 0.1.0, protocol 1.reputecast adapter checkcalls the signedhealthoperation and records the result.reputecast adapter listshows each installed adapter with its address, when its secret was issued, its last check and how many providers use it. Within 15 seconds every ReputeCast process picks the adapter up, and Example ESP appears under Community adapters on Sending providers, then Adapters and in Add provider in every workspace. -
Map your own provider. Stop
pnpm dev, then:- Change
idinsrc/adapter.tsandmanifest.json, and thename,maintainerand links. The id (2 to 40 characters:a-z,0-9and-, starting with a letter) becomescommunity:<id>and appears in webhook URLs, so keep it stable across versions. - Rewrite
send(orsendBatchfor a batch API),verifyWebhookandnormalizeEventfor your provider’s API, with the helpersverifyHmac,verifyTimestampedHmac,verifyBasicAuthandclassifyBounce. Send every header inmsg.headersand passmsg.idback on events. - In
manifest.json, declare the capabilities and terms, the provider’spricing(required), the connection form underconfig(mark keys and tokens"secret": true) and the provider’s hosts underpermissions.network. The protocol reference lists what each capability makes ReputeCast call. - The manifest’s texts (
name,description,terms.note,pricing,dedicatedIps, theconfiglabels and help) show as written, in English for every reader: a community adapter has no message catalog. The badges, the Adapters tab’s words and the runtime’s errors are ReputeCast’s and show in each reader’s language. - Record a few real webhooks, with personal data removed, in
test/fixtures/webhooks.jsonwith the events they must produce, and adapttest/fake-api.ts.
Run
pnpm testandpnpm typecheckuntil both pass. Then install the manifest again withreputecast adapter install ./manifest.json --url http://127.0.0.1:4600. A new id is a new install: it prints a new secret for.env, andreputecast adapter remove example-espuninstalls the template’s adapter. The same id updates the installed manifest and keeps the secret. Startpnpm devagain. - Change
-
Ship it as a container:
Terminal pnpm build && pnpm startdocker build -t example-esp-adapter .docker run -d --name example-esp -p 127.0.0.1:4600:4600 \-e REPUTECAST_ADAPTER_SECRET=rcas_… example-esp-adapterpnpm startruns the build with Node’s permission model. The template’s image runs as the non-rootnodeuser, reads only its own files, can’t start child processes or workers, and answers itsHEALTHCHECKon/healthz. It installs withpnpm install --frozen-lockfile, so commit thepnpm-lock.yamlthat step 2 created. Limit the container’s network egress to the provider’s API where your platform allows it.Publish the image and
manifest.jsonat anhttps://URL. Operators install it withreputecast adapter install <manifest URL> --url <where it runs>, or with Install from registry on the Adapters tab; the tab shows no secret, so they issue one withreputecast adapter secret <id>and set the address withreputecast adapter set-url <id> <url>.
What you built
Section titled “What you built”You have an adapter process that passes the same contract scenarios as ReputeCast’s built-in adapters, installed on your instance for every workspace, and signed in both directions with its own secret. Once it talks to your real provider, any workspace admin connects it from Add provider like a built-in adapter: the credentials they enter are sealed in ReputeCast and sent with each call, and the adapter never stores them.
reputecast adapter remove <id> uninstalls it again; ReputeCast refuses while a connected provider uses it. The CLI reference lists every adapter command.