A Gateway to an External Service
The shop takes money through a payment provider: an outside company with an HTTP API, a secret key, and rules of its own. Three components need it — the one that starts a payment, the one that receives the provider’s notifications, the one that tracks memberships.
The naive way is to let each of them call the provider with context.fetch. Then the secret key
is configured in three places, each component learns the provider’s statuses and quirks on its
own, each test of each of them needs the key and the network, and the day the provider changes
its API you edit all three.
§One component speaks to the provider
payments.gateway is the only component that knows the provider exists. It has no entity. Its
operations are the questions the shop asks, in the shop’s words, and everybody else calls them
like any other operation.
billing.topups ──────┐
payments.webhooks ───┼── call ──> payments.gateway ── HTTPS ──> the payment provider
billing.memberships ─┘ (key, signing secret) The three callers are built elsewhere: payments.webhooks in Receiving Webhooks, billing.topups there and in Taking a Payment, End to End, billing.memberships in Mirroring a Provider’s Object.
# components/payments.gateway/manifest.toa.yaml
name: gateway
namespace: payments
operations:
checkout: # a payment page for the customer
type: effect
errors: [INVALID_ORIGIN, UNAVAILABLE]
input:
type: object
properties:
customer: { type: string }
amount: { type: integer, minimum: 100 }
success: { type: string }
cancel: { type: string }
required: [customer, amount, success, cancel]
output:
type: object
properties:
url: { type: string }
retrieve: # a checkout as the provider holds it now
type: effect
errors: [UNKNOWN_CHECKOUT, UNAVAILABLE]
input:
type: object
properties:
checkout: { type: string }
required: [checkout]
decode: # a notification, once its signature is verified
type: computation
errors: [DECODE]
input:
type: object
properties:
payload: { type: string }
signature: { type: string }
required: [payload, signature]
configuration:
schema:
properties:
url: { type: string, format: uri }
key: { type: string, format: secret }
signing: { type: string, format: secret }
origins:
type: array
items: { type: string }
required: [url, origins] The key and the signing secret are secrets: the Context,
which gains these values under its configuration, holds only their names.
# context.toa.yaml
configuration:
payments.gateway:
url: https://sandbox.payments.example.com
url@production: https://payments.example.com
key: $PAYMENTS_KEY
signing: $PAYMENTS_SIGNING_SECRET
origins: [https://store.example.com] Each operation is a few lines, because the work is behind one function that picks who answers:
// components/payments.gateway/operations/checkout.ts
import type { CheckoutInput, Context } from '../types/index.d.ts'
import { connect } from '../lib/connect.ts'
export async function effect (input: CheckoutInput, context: Context) {
const { origins } = context.configuration
const allowed = (url: string) => origins.some((origin) => url.startsWith(`${origin}/`))
if (!allowed(input.success) || !allowed(input.cancel)) return new Error('INVALID_ORIGIN')
return await connect(context).checkout(input)
} // components/payments.gateway/operations/retrieve.ts
import type { Context, RetrieveInput } from '../types/index.d.ts'
import { connect } from '../lib/connect.ts'
export async function effect (input: RetrieveInput, context: Context) {
return await connect(context).retrieve(input.checkout)
} // components/payments.gateway/operations/decode.ts
import type { Context, DecodeInput } from '../types/index.d.ts'
import { connect } from '../lib/connect.ts'
export async function computation (input: DecodeInput, context: Context) {
return connect(context).decode(input.payload, input.signature)
} checkout refuses a return address outside the shop’s own sites. Without the check, anyone could
make the shop’s payment page send a customer wherever they like afterwards.
§The real client
// components/payments.gateway/lib/client.ts
import type { CheckoutInput, Context } from '../types/index.d.ts'
import { createHmac, timingSafeEqual } from 'node:crypto'
export function client (context: Context, key: string) {
const { url, signing } = context.configuration
async function request (method: string, path: string, body?: unknown) {
let response
try {
response = await context.fetch(url + path, {
method,
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(10_000)
})
} catch {
return new Error('UNAVAILABLE') // not reached, or no answer in ten seconds
}
if (response.status === 404) {
await response.body?.cancel()
return null
}
if (response.status === 429 || response.status >= 500) {
await response.body?.cancel()
return new Error('UNAVAILABLE')
}
if (!response.ok) throw new Error(`The payment provider answered ${response.status}`)
return await response.json()
}
return {
async checkout ({ customer, amount, success, cancel }: CheckoutInput) {
const created = await request('POST', '/checkouts', {
amount,
currency: 'USD',
success_url: success,
cancel_url: cancel,
metadata: { customer }
})
return created instanceof Error ? created : { url: created.url }
},
async retrieve (id: string) {
const found = await request('GET', `/checkouts/${encodeURIComponent(id)}`)
return found ?? new Error('UNKNOWN_CHECKOUT')
},
decode (payload: string, signature: string) {
const secret = signing?.unwrap()
if (secret === undefined || secret === '')
throw new Error('The signing secret of the payment provider is not configured')
const expected = createHmac('sha256', secret).update(payload).digest('hex')
const given = Buffer.from(signature)
if (given.length !== expected.length || !timingSafeEqual(given, Buffer.from(expected)))
return new Error('DECODE')
return JSON.parse(payload)
}
}
} The provider here is an invented one; a real provider documents its own paths, fields and signature scheme, and usually ships a library that implements them. What carries over is the shape:
- Every answer of the provider becomes one of three things. A value in the shop’s words
(
{ url }, not the provider’s forty fields). A returned error for an answer the callers are built for:UNKNOWN_CHECKOUT, andUNAVAILABLEfor “not now, ask again”. An exception for what nobody expected — a400that means the request itself is wrong, which is a bug to fix. - The customer’s id travels through the provider.
metadata.customeris set when the checkout is created, and comes back in every later answer and notification about it. That is how a notification that arrives on its own, days later, is matched to a customer. - The timeout is set here, once, and so would be retries of requests that are safe to repeat.
§A stub for an empty key
// components/payments.gateway/lib/connect.ts
import type { Context } from '../types/index.d.ts'
import { client } from './client.ts'
import { stub } from './stub.ts'
export function connect (context: Context) {
const key = context.configuration.key?.unwrap()
if (key !== undefined && key !== '') return client(context, key)
if (context.env !== 'local') throw new Error('The key of the payment provider is not configured')
return stub
} // components/payments.gateway/lib/stub.ts
import type { CheckoutInput } from '../types/index.d.ts'
export const stub = {
async checkout ({ customer }: CheckoutInput) {
if (customer.startsWith('dead')) return new Error('UNAVAILABLE')
return { url: 'https://pay.test/checkout' }
},
// a checkout is the JSON its id is
async retrieve (id: string) {
try {
return JSON.parse(id)
} catch {
return new Error('UNKNOWN_CHECKOUT')
}
},
// a notification is its body, unsigned
decode (payload: string) {
try {
return JSON.parse(payload)
} catch {
return new Error('DECODE')
}
}
} A freshly generated .env holds an empty key, so a developer’s machine and a test run get the
stub with no setup. context.env is the environment the
application runs as. The stub keeps nothing and asks nobody: what it answers is decided by what it
is given. A test that needs a paid checkout passes one as the id; a test that needs the provider
to be down uses a customer whose id starts with dead:
$ npm run restart
$ npx toa call payments.gateway.retrieve
"{ input: { checkout: '{"id":"co_1","status":"paid","amount":5000}' } }" { id: 'co_1', status: 'paid', amount: 5000 } $ npx toa call payments.gateway.retrieve "{ input: { checkout: 'nonsense' } }" answers the error UNKNOWN_CHECKOUT.
Anywhere but the local environment a missing key stops the call with an exception. A deployment that forgot its secret must fail loudly, not accept payments into a stub. The same arrangement for a mail provider is walked through in Call an External Service.
§Keys reach one process
Secrets are handed to the processes that run the components declaring them. Put the gateway and the components that exist only for the provider into a composition of their own:
# context.toa.yaml
compositions:
- name: billing
components:
- payments.gateway
- payments.webhooks
- billing.topups
- billing.memberships The provider’s key now reaches these pods and no others, and a change to billing restarts nothing
else. Decide What Runs Together has the rest; Supply Secrets is how PAYMENTS_KEY gets its value in a deployment.
§Why a component and not a shared library
A shared function would remove the duplicated code, and nothing else: every component that imports it still needs the key, still talks to the network in its tests, and still has to be released when the provider changes.
Whatever is outside the application gets exactly one component that speaks to it; the rest of the application speaks to that component.
The price is one more call between components, which is small next to a request that leaves the building.
§What can still go wrong
The provider is down. The gateway answers UNAVAILABLE, and each caller decides what that
means for it. An operation serving a customer returns it, and the customer is told to try again.
An operation reached by an event or a task has
nobody to tell: there a returned error ends the work for good, so it throws when the work must be
tried again, or leaves it to another route it knows will come. Which of the two is right for a
notification from the provider is in Receiving Webhooks.
The operation runs twice. A gateway operation is an effect: nothing remembers that it
ran, so a caller that runs again makes the request again. Reads do no harm. A second checkout creates a second payment page that nobody opens, which costs nothing. A request that moves
money — a refund, a charge of a saved card — must carry a key the provider recognizes the repeat
by, taken from something that is the same on every run, such as the id of the order being
refunded: Keys for external services.
The provider is slow or limits requests. Whatever softens that — a timeout, a short cache of a price list that every page asks for — lives in the gateway, where one place decides it for all callers.
The stub drifts from the provider. The stub answers in the shape the client does, refusals included, and nothing checks that but you. Keep both behind the same small set of methods, as above, and run the scenarios against the provider’s sandbox before a release that touches the client.
§Rules of thumb
- One external service, one component, no entity. Other components never import its client and never see its key.
- Name operations and errors in the application’s language; turn the provider’s statuses into values, declared errors and exceptions inside the gateway.
- Have an error that means “ask again later”, distinct from “no”.
- Put your own id into the provider’s metadata when you create something there.
- With an empty key, a stub answers locally and the component fails anywhere else.
- Let a test choose the stub’s answer through the input.
- Give money-moving requests a key the provider deduplicates by.
- Keep the provider’s components in one composition, so its secrets reach one process.