Zero to a working transactional send against the PostKit API, from a trusted
backend. Read this if you are wiring a server-side service to
POST /emails/send, either through @singleton-sd/post-kit-client or raw
HTTP.
Working example: examples/backend-password-reset/.
The API key is a long-lived tenant credential:
- It is stored in Azure Key Vault (
ssd-postkit-kv-prod-ae) and injected into your service's environment at deploy time. It must never be committed, and never pasted into an issue, PR, or log line. - It must never appear in browser code — not in a bundle, not in a
NEXT_PUBLIC_*-style variable, not in an inline script. Public UIs POST to your own server endpoint, which then calls PostKit with the key.
- A tenant API key (Key Vault, as above), exposed to your process as
POSTKIT_API_KEY. - The API base URL, e.g.
https://<function-app>.azurewebsites.net/api, asPOSTKIT_URL. - A compiled template published to the tenant's storage. The template key is
what you pass as
template; an unpublished key returns404/TEMPLATE_NOT_FOUND.
POST {POSTKIT_URL}/emails/send
| Header | Required | Notes |
|---|---|---|
Authorization |
yes | Bearer <POSTKIT_API_KEY>. The tenant is resolved server-side from this credential |
Content-Type |
yes | application/json |
x-correlation-id |
no | Your own trace id. If omitted the API generates one |
Tenant identity is never sent in the body.
{
"template": "auth.password-reset",
"to": "jane@example.com",
"variables": {
"name": "Jane Doe",
"resetUrl": "https://app.example.com/reset?token=<single-use-token>"
}
}| Field | Type | Rules |
|---|---|---|
template |
string |
Non-empty; only A-Z a-z 0-9 . _ - (path characters are rejected) |
to |
string |
Single recipient address, basic local@domain.tld validation |
variables |
Record<string, string> |
A JSON object whose every value is a string. A number, boolean, or nested object is rejected with MISSING_VARIABLES. Must cover every name in the template's metadata.json variables (tenant branding defaults can satisfy some of them) |
200 OK, with an X-Correlation-Id response header:
{ "id": "3f6a0f2e-...", "status": "sent" }id is the correlation id of the send — record it. status is always
"sent" on a 200.
Any non-2xx status returns:
{
"error": "Template not found",
"code": "TEMPLATE_NOT_FOUND",
"correlationId": "3f6a0f2e-..."
}Branch on code, never on error text.
The same request without Node:
curl -sS -X POST "$POSTKIT_URL/emails/send" \
-H "Authorization: Bearer $POSTKIT_API_KEY" \
-H 'Content-Type: application/json' \
-H "x-correlation-id: $(uuidgen)" \
-d '{
"template": "auth.password-reset",
"to": "jane@example.com",
"variables": {
"name": "Jane Doe",
"resetUrl": "https://app.example.com/reset?token=REPLACE_ME"
}
}'Read both variables from the environment; do not inline the key.
pnpm add @singleton-sd/post-kit-clientimport { PostKitClient } from '@singleton-sd/post-kit-client';
const postKit = new PostKitClient({
endpoint: process.env.POSTKIT_URL!,
apiKey: process.env.POSTKIT_API_KEY!,
});
const result = await postKit.send(
{
template: 'auth.password-reset',
to: 'jane@example.com',
variables: {
name: 'Jane Doe',
resetUrl: 'https://app.example.com/reset?token=REPLACE_ME',
},
},
{ correlationId: 'my-trace-01' },
);
console.log(result.id, result.status); // "my-trace-01 sent"Pass correlationId on send() (or as a client constructor default) to send
x-correlation-id. Omit it and the API generates one. result.id on success
contains the server correlation ID. On HTTP failures,
PostKitRequestError.correlationId contains it when the error body or
X-Correlation-Id response header provides it.
| Option | Default | Notes |
|---|---|---|
endpoint |
— | Required. Base URL; a trailing slash is stripped. send() appends /emails/send |
apiKey |
— | Required. Sent as Authorization: Bearer |
timeout |
30_000 |
Milliseconds. 0 disables the client timeout — the request then runs until your own AbortSignal fires, or indefinitely |
correlationId |
— | Optional default x-correlation-id for every send() |
fetch |
globalThis.fetch |
Injectable fetch. Use it in tests so no socket is opened |
Both endpoint and apiKey throw synchronously from the constructor if
empty.
send(request: SendRequest, options?: { signal?: AbortSignal; correlationId?: string })
resolves to SendResponse or throws PostKitRequestError. A per-call signal
is combined with the client timeout, so whichever fires first wins:
await postKit.send(request, {
signal: AbortSignal.timeout(5_000),
correlationId: 'my-trace-01',
});Invalid correlationId values (empty, over 128 characters, or characters
other than alphanumeric / hyphen / underscore) throw PostKitRequestError with
code INVALID_CORRELATION_ID before any network call.
If your signal aborts, the original AbortError propagates unchanged; if
the client's own timeout fires, you get a PostKitRequestError with code
TIMEOUT.
| Property | Meaning |
|---|---|
code |
A PostKitErrorCode from the API body, or 'TIMEOUT' / 'NETWORK_ERROR' / 'INVALID_CORRELATION_ID', or HTTP_<status> when the error body was not JSON |
status |
HTTP status; undefined for timeouts and network failures |
correlationId |
From the error body when present — log it |
message |
The API's error text, or a generated fallback |
code |
Typical status | Retry? | What to do |
|---|---|---|---|
UNAUTHENTICATED |
401 | no | Credential missing or malformed — check the Authorization header |
UNAUTHORIZED |
403 | no | Well-formed key that maps to no tenant — the key was rotated or is for another environment |
INVALID_RECIPIENT |
400 | no | to failed validation, or the body was not a JSON object |
INVALID_TEMPLATE |
400 | no | template missing, unsafe, or the stored artifact is unparseable |
MISSING_VARIABLES |
400 | no | A declared variable is absent, or a value was not a string |
TEMPLATE_NOT_FOUND |
404 | no | Publish the template for this tenant first |
STORAGE_FAILURE |
503 / 500 | yes on 503 | Configuration or template storage is temporarily unavailable |
PROVIDER_FAILURE |
503 | yes | Transient provider error, rate limit, or provider misconfiguration |
PROVIDER_FAILURE |
502 / 500 | no | Provider rejected the message permanently, or an unexpected server error — investigate with the correlation id |
TIMEOUT |
— | yes (idempotent) | Client-side timeout; delivery status is unknown — the send may still have happened |
NETWORK_ERROR |
— | yes (idempotent) | Transport failure or a response-body read failure after headers arrived; delivery status is unknown — the API may have accepted the send before the body failed |
INVALID_CORRELATION_ID |
— | no | Fix the correlationId on send() or the client constructor — must be 8–128 alphanumeric / hyphen / underscore characters |
Retry the "yes" rows with capped exponential backoff and jitter. Treat both
TIMEOUT and NETWORK_ERROR as delivery unknown — make retries idempotent
at your layer. Never retry a 4xx — the same request will fail identically.
import { PostKitRequestError } from '@singleton-sd/post-kit-client';
import { PostKitErrorCode } from '@singleton-sd/post-kit-types';
try {
await postKit.send(request);
} catch (err) {
if (!(err instanceof PostKitRequestError)) throw err;
// Log the correlation id — support needs it to find the send server-side.
logger.error('postkit.send.failed', {
code: err.code,
status: err.status,
correlationId: err.correlationId,
});
const retryable =
err.code === 'TIMEOUT' ||
err.code === 'NETWORK_ERROR' ||
((err.code === PostKitErrorCode.STORAGE_FAILURE ||
err.code === PostKitErrorCode.PROVIDER_FAILURE) &&
err.status === 503);
if (retryable) {
// schedule a retry with backoff
}
}PROVIDER_FAILURE and STORAGE_FAILURE are retryable only on HTTP 503, not
on 502 or 500.
Log code, status, and correlationId (when present) on HTTP failures, and
id on every success — both are the same correlation id the API logs when the
server handled the request, and it is the only handle that ties your request to
the server-side trace. Never log the API key,
the rendered email, or a reset/verification URL.
examples/backend-password-reset/— runnable version of everything above, with specs against a mockfetch.packages/post-kit-client/README.md— SDK reference.docs/architecture/overview.md— how the API, template storage, and email provider fit together.