Skip to content

Render an email from your own site

ebitex Audience can send an email whose content is a page in one of your Content sites, so the people who build your email templates work in the same place as the people who build your website. Something has to turn that page into email markup, and there are two ways to arrange it.

  • The hosted renderer. The default. Choose it on the channel and Audience asks an ebitex-run service to render the page. It reads the page with the delivery key you gave the channel and returns a plain, email-safe layout. Nothing to host.
  • Your own renderer. Choose My own renderer on the channel and give Audience an https address. Audience sends your app a signed request for each page it needs, and your app answers with the subject, HTML and text. Use this when you want your own layout, or when your website app already knows how to render your pages.

Either way, Audience never sends the renderer anything about a recipient. A request names a page and the audiences a group of recipients share, and any {{individual.firstName}}-style placeholder in your output is filled in by Audience for each person.

Audience sends a POST to /render/v1 on the address you gave, with a JSON body:

{
"channelId": "11111111-2222-4333-8444-555555555555",
"channel": "email",
"site": "5ceebe71-46df-4eed-a84e-af54db25088b",
"path": "/coffees/guatemala-antigua",
"locale": null,
"context": { "audiences": [] },
"deliveryKey": "…"
}
  • site and path name the page, exactly as the Content Delivery API addresses it.
  • locale is a locale code, or null for the site’s default.
  • context.audiences is the audiences this render is for, as ids. Pass them on to the Delivery API’s ctx parameter so the page’s audience variants resolve for that group. Empty means the page as anyone sees it.
  • deliveryKey is a key for reading that site’s published content. Use it for this request only. It is the key you gave the channel, not one of yours: never log it or store it.
{
"subject": "Guatemala Antigua, El Pilar",
"html": "<!doctype html>…",
"text": "Guatemala Antigua, El Pilar\n\n…",
"tokensUsed": ["individual.firstName"],
"contentHash": "70f70a6839734b6c…",
"audiencesUsed": ["cccccccc-0000-4000-8000-0000000000aa"]
}
  • tokensUsed lists the placeholders your output leaves in place. Audience checks them against the values it may fill in, and skips a message that uses one it may not.
  • contentHash identifies exactly what you rendered: the same published page must always give the same hash.
  • audiencesUsed is optional. When a page has audience variants, it lists the audiences they read, so Audience can render once for each group of recipients instead of once for each person. Send it when the request’s audiences are empty.

A page that is not published is a 404. A page that exists but has nothing to put in a message is a 422 with a message Audience shows to the person who set up the send. Any other failure is a 500, and Audience tries again a little later: while your renderer is down the channel waits, and nothing is dropped.

Every request carries an X-Ebitex-Signature header, made with the secret Audience shows once when you connect your renderer to a channel. It is the same scheme as webhook signatures: v1,t=<unix seconds>,s=<hex>, an HMAC-SHA256 of {timestamp}.{raw body}. Verify it over the exact bytes you received, refuse a timestamp more than five minutes away from now, and look the secret up by channelId.

You do not have to write any of that. createRenderHandler in @ebitex/content-sdk/render checks the signature and the replay window, reads at most 64 KB, and answers the right status for each case. It is built on the standard Request and Response, so it runs in a Node server, a Cloudflare Worker or a Next.js route handler.

render-handler.ts
// Serves ebitex's render protocol from your own app, so ebitex Audience can turn one of your pages into an email.
// The handler checks the request's signature, then calls `render` with the page, the locale and the audiences to render for.
import { createRenderHandler, renderEmail, RenderNotFoundError, RenderUnrenderableError, type RenderRequest, type RenderResult } from '@ebitex/content-sdk/render'
const CONTENT_API = 'https://api.ebitex.io'
async function render(request: RenderRequest): Promise<RenderResult> {
// Read the published page with the delivery key the request carries. Use it for this call only: it is the sender's key for this site, not yours.
const url = new URL(`${CONTENT_API}/content/delivery/v1/path${request.path}`)
url.searchParams.set('site', request.site)
url.searchParams.set('ctx', JSON.stringify({ audiences: request.context.audiences }))
// A page usually points at a component rather than carrying it, so ask for references to be expanded.
url.searchParams.set('resolve', '4')
if (request.locale) url.searchParams.set('locale', request.locale)
const response = await fetch(url, { headers: { Authorization: `Bearer ${request.deliveryKey}` } })
if (response.status === 404) throw new RenderNotFoundError()
if (!response.ok) throw new Error(`Content answered ${response.status}`)
const page = (await response.json()) as { kind: string; title?: string; presentation: unknown }
if (page.kind !== 'presentation') throw new RenderNotFoundError()
// Use your own email layout here, or this default one. Leave `{{individual.firstName}}`-style placeholders in the output:
// the sender fills them in for each person, so nothing about a recipient is ever sent to you.
const email = renderEmail({ title: page.title, presentation: page.presentation })
if (!email.hasContent) throw new RenderUnrenderableError('This page has nothing that can go in an email.')
return {
subject: email.subject,
html: email.html,
text: email.text,
tokensUsed: email.tokensUsed,
// The same published page must always give the same hash, so use something derived from what you read.
contentHash: await sha256(JSON.stringify(page)),
}
}
async function sha256(text: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('')
}
// The secret is the one ebitex Audience shows once, when you connect your own renderer to a channel. Look it up by the channel's id: here, one channel.
const CHANNEL_ID = '00000000-0000-4000-8000-000000000000'
const RENDER_SECRET = 'the-secret-shown-when-you-connected-this-renderer'
export const POST = createRenderHandler({
secretFor: (channelId) => (channelId === CHANNEL_ID ? RENDER_SECRET : undefined),
render,
})

renderEmail, from the same entry, turns a delivered page into an email: a heading, paragraphs, pictures and buttons in a single-column table layout with inline styles. It reads a page by what its fields are called and shaped like, because it does not know your templates. It escapes everything, keeps only http, https and mailto addresses, and tells you through hasContent when a page produced nothing, so you can refuse to send a blank email. If you want your own look, render your own markup and use only createRenderHandler.

A page’s presentation often points at a component instead of carrying it, so ask the Delivery API to expand references with the resolve parameter. Without it the page can arrive with no content to render.