Skip to content

Emails to your users

Markdown

Your application can email the people connected to it, and nobody else. You name the person by the user_ref you already hold; Apigami lays the email out, sends it as « Your app via Apigami » from {slug}@apigami.tech, and never hands you the address. Nobody can reply to it: a reply would tell you the address.

POST /call
{
"user_ref": "YOUR_USER_REF",
"op": "emails.send",
"params": {
"kind": "transactional",
"subject": "Your session on example.com expired",
"blocks": [
{ "type": "callout", "tone": "warning", "title": "Nothing was read since yesterday", "text": "Open Chrome again so the watch can go on." },
{ "type": "button", "label": "Open example.com", "url": "https://example.com/" }
]
}
}
Result
{ "result": { "email_id": "4b1e…", "status": "sent" } }

emails.send is answered by Apigami itself, never by a browser: it takes no domain, opens no approval window, and reaches the person whether or not a browser of theirs is online.

  1. Declare the permission in your manifest, as you declare your sites (Permissions: emails):

    "x-apigami": { "permissions": ["emails"] }

    Only an application with hosted code has a manifest, so only it sends emails. A brick that declares the permission gives it to the applications that use it; the email always leaves in the name of the application the person connected to.

  2. Deploy and publish the version. The connect page then shows two boxes, both unticked: « X needs to be able to email you », which the person must tick to continue, and an optional one for your news.

  3. Being connected is what lets you write to someone: the first box is the connect page’s own, and Apigami records nothing of it. The people connected before you declared emails receive your service emails as well; only your news wait for someone to tick their box.

Caller Credential The recipient
Your server your API key, apg_… user_ref in the body
The action of a cron, for several people env.APIGAMI_KEY, the production version’s key user_ref in the body
An action, a page, a trigger of audience extension the run token in X-Apigami-Run the run’s person only: no user_ref
You, from your AI the MCP tools below a user_ref, yourself, or everyone who accepted your news
Any of the first three, with "to": "team" in params the same every member of your application’s team

A run with nobody behind it, such as an open_action a payment provider calls, answers 403 this run serves nobody, unless it writes to your team. Record the work in your database, and send the email from the action of a cron of audience application, with the version’s key, env.APIGAMI_KEY; it reads the people your application reaches with GET /v1/users.

actions/notify.ts
// Refusals that concern one person: marked done, since retrying changes nothing. `emails rate limited` leaves the
// person for the next round; any other refusal, such as `app not reviewed`, concerns the application and stops it.
const RECIPIENT_REFUSALS = new Set(['unknown user_ref', 'not paired', 'app is private', 'recipient unreachable'])
// The action of an `application` cron. The key acts for everyone connected, and any path of your address takes
// requests from anyone: read it only once the run token proved this is that run, in production.
export const notifyExpiredSessions = async (request: Request, env) => {
const me = await fetch(`${env.APIGAMI_API_URL}/v1/me`, { headers: { Authorization: `Bearer ${request.headers.get('X-Apigami-Run')}` } })
const run = me.ok ? await me.json() : null
const isOwnCronRun = run?.action === 'notifyExpiredSessions' && run.user_ref === null
&& run.caller_app_id === env.APIGAMI_APP_ID && run.production === true
if (!isOwnCronRun) return Response.json({ error: 'unauthorised' }, { status: 401 })
// One select, then one update per person, well under the 1,000 queries one request may make: the next round
// takes the rest.
const { results } = await env.DB.prepare('select user_ref from expired_sessions where notified_at is null limit 300').all()
let notified = 0
for (const { user_ref } of results) {
const answer = await fetch(`${env.APIGAMI_API_URL}/call`, {
method: 'POST',
headers: { Authorization: `Bearer ${env.APIGAMI_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
user_ref,
op: 'emails.send',
params: {
kind: 'transactional',
subject: 'Your session on example.com expired',
blocks: [{ type: 'text', text: 'Open Chrome again so the watch can go on.' }],
},
}),
})
const error = answer.ok ? null : (await answer.json().catch(() => ({}))).error
if (error === 'emails rate limited') continue
if (error !== null && !RECIPIENT_REFUSALS.has(error)) {
return Response.json({ error: `emails stopped: ${error ?? answer.status}`, notified }, { status: 502 })
}
if (error !== null) console.warn('email not sent', user_ref, error)
await env.DB.prepare('update expired_sessions set notified_at = ?2 where user_ref = ?1').bind(user_ref, new Date().toISOString()).run()
notified += 1
}
return Response.json({ notified })
}

A contact form on a public page has nobody connected behind it, and the person to tell is you. With "to": "team" in params, the email goes to every member of your application’s team, from any of the callers above, a visitor’s run included. List the action in open_actions, and the page calls it with apigami.call('contact', fields), no visitor signed in: the run Apigami opens for it serves nobody, and may still tell the team.

actions/contact.ts
// In `open_actions`: anyone may call it, so it checks what it is sent and never echoes it to the visitor.
export const contact = async (request: Request, env) => {
const { name, email, message } = await request.json()
const answer = await fetch(`${env.APIGAMI_API_URL}/call`, {
method: 'POST',
headers: { 'X-Apigami-Run': request.headers.get('X-Apigami-Run')!, 'Content-Type': 'application/json' },
body: JSON.stringify({
op: 'emails.send',
params: {
to: 'team',
kind: 'transactional',
subject: `New message from ${name}`,
blocks: [{ type: 'text', text: message }, { type: 'item', title: 'Reply to', text: email }],
},
}),
})
return Response.json({ sent: answer.ok }, { status: answer.ok ? 200 : 502 })
}

A team email needs no permissions, which stand for the consent of the people connected, and none of their limits: it counts neither among a person’s 5 a day nor against your news. It is a service email, never marketing, at most 50 a day per member from one application, and its footer leads to your application’s page in the dashboard. Its answer counts the members reached: { "status": "sent", "sent": 2, "not_sent": {} }.

kind For Who receives it
transactional The person’s own use of your application: a session expired, an order ready, a task failing for three days Everyone connected to your application
marketing Your news, something a little commercial Only those who ticked your news box

A marketing email leaves on a separate stream, with Postmark’s unsubscribe link in its footer and a one-click unsubscribe beside the sender in Gmail (see the footer). Sending news as transactional is what gets an application suspended.

You send JSON, never HTML. Apigami draws the header, your application’s logo and name, and the footer, then your blocks in order between them. The email reads in light and dark mode and on a phone, and its plain-text part is written from the same blocks.

Block Fields
hero title, and optionally text, image: { url, alt }, button: { label, url }
heading text, a subheading
text text: a string, or spans [{ text, bold?, italic?, url? }], the only formatting a paragraph has
item title, and optionally text and image: { url, alt }: one line of a list of news or products
callout tone (info, success, warning, danger), title, and optionally text
button label, url
image url, alt: a full-width image and what it shows
divider nothing: a line
A news email
{
"kind": "marketing",
"subject": "Three new things in Price watch",
"blocks": [
{ "type": "hero", "title": "Price watch now follows promotions", "text": "Every Monday, the products whose price dropped.", "image": { "url": "https://example.com/news/hero.png", "alt": "A chart of falling prices" } },
{ "type": "item", "title": "Weekly digest", "text": "One email instead of ten.", "image": { "url": "https://example.com/news/digest.png", "alt": "An envelope" } },
{ "type": "item", "title": "Two shops more", "text": "Add them from the settings page." },
{ "type": "divider" },
{ "type": "text", "text": [{ "text": "Questions? " }, { "text": "Read the guide", "url": "https://example.com/guide", "bold": true }] }
]
}

Limits, each refused as 400 invalid email: {path}: {reason}, naming the first field at fault, such as invalid email: blocks[3].url: must be https:

  • a subject of one line, 150 characters at most;
  • 1 to 50 blocks, and no field beyond those above;
  • every link and image https://; images are shown as they are, from your server;
  • the rendered email under 100 kB, where Gmail cuts a message.

Opens and clicks are never tracked.

Email The footer says The link
transactional « You are receiving this email because you use X. Stop using X » The person’s dashboard, asking whether they stop using X: then X no longer reaches their browser nor writes to them
marketing « You are receiving news from X because you asked for it. Stop receiving it » Postmark’s unsubscribe page, Apigami’s email provider, in one click without signing in; X keeps working

That link, and the unsubscribe button a mail client shows beside the sender of a news email, Gmail’s among them, are both Postmark’s, and stop the news of every application for that address. Apigami then withdraws the person’s news of every application: they leave each audience, and a news email to them answers marketing not accepted by this user. Neither stops the service emails, and a service email carries no such button.

In French, the footer reads « Vous recevez cet e-mail parce que vous utilisez X. Arrêter d’utiliser X » and « Vous recevez les nouveautés de X parce que vous les avez acceptées. Ne plus les recevoir », with « d’X » before a name that starts with a vowel. The language is the person’s own.

Nobody turns off the service emails of an application they keep: they are part of how it works. Leaving the application removes the connection, and its news acceptance with it.

  • The recipient is always a person connected to your application, whose address is verified, and who ticked your news box for a marketing email.
  • 5 emails per person per 24 hours, every application together. There is no limit per application. A send to everyone who accepted your news that crosses a single email to the same person at the same moment can let one email past the five, never more than one.
  • One news email per person per 24 hours from your application: a second marketing email to someone who received one from it in the last 24 hours is refused, news already sent to this user today, a send to everyone included. Sending the same news to everyone again therefore reaches nobody twice.
  • An address that bounced for good, or that reported an email as spam, receives nothing more from any application: the send answers recipient unreachable. One that unsubscribed from the news receives no more news from any application.
  • Too many complaints suspend your application’s sending: 3 in 7 days, or, from the third complaint over 30 days, more than 0.1 % of what it sent over those 30 days. A single complaint on a small audience suspends nothing. It stays suspended until Apigami lifts it: write to contact@apigami.com.
Status error What to do
400 invalid email: blocks[3].url: must be https Fix the field it names
403 emails not permitted for this app Declare "permissions": ["emails"], deploy and publish
403 emails suspended for this app Write to contact@apigami.com
409 deploy this app once with app_deploy before it sends: … Deploy once: the sender’s address comes with the first deployment
404 unknown user_ref Use a reference your team holds, from POST /connect/token or GET /v1/users
403 not paired The person is not connected to your application, or left it
403 app is private The person left the team of your private application
403 marketing not accepted by this user They did not accept your news: send only what concerns their use
403 recipient unreachable The address is unverified, disabled, bounced or complained: nothing to retry
403 this run serves nobody: … Record the work, and send from the action of a cron with env.APIGAMI_KEY; or, to your own team, add "to": "team"
400 an email to the team names nobody: remove \user_ref`` Send "to": "team" without user_ref
403 no member of this app's team can receive it: … Each member needs a verified address
403 only the application this run belongs to writes to its team, … A brick never writes to the team of the application that calls it
403 a page's token writes to its person only: … Send to the team from an action, which the page calls with apigami.call
429 team emails rate limited Every member had 50 team emails of your application in 24 hours: try tomorrow
429 emails rate limited That person had 5 emails in 24 hours: try tomorrow
429 news already sent to this user today That person had your news in the last 24 hours: try tomorrow, or send only what concerns their use
502 the email could not be sent, try again later Try again in a few minutes

Nothing is sent when a call is refused.

Connected to Apigami’s MCP server, a member of your team’s AI has three tools:

  • app_email_preview renders { kind, subject, blocks } without sending it: the HTML in light and in dark, the plain-text part, and every reason a send would be refused now.
  • app_email_send sends it, to a user_ref, to "me" to see it in a real mailbox, or, for a marketing email, to "all_marketing": everyone who accepted your news.
    • To a user_ref or to "all_marketing", the permission is read on your production version, as for your API key, and for marketing the person must have ticked your news box.
    • To "me", it is your own trial: the permission is read on your newest valid version, so you can try before you publish, you need no box ticked, and your news trials are not held to one a day. You still need to be connected to the application, with a verified address, within the 5 emails a day, and the application not suspended.
    • "all_marketing" takes two calls. The first, without confirm, sends nothing and answers how many people the news would reach: everyone who accepted it, before anyone is held back. The second, with confirm set to that number, sends it by batches of up to 500 within that same call, then answers how many received it and how many were held back, and why. If the number changed in between, nothing is sent and the new number is answered.
    • Each person is checked when their batch leaves: an address that cannot receive, 5 emails already received in 24 hours, or your news already received in 24 hours holds them back, and they are counted. Calling again within 24 hours therefore reaches nobody twice.
    • One send to everyone at a time per application: another one, while the first call has not answered, is refused with a broadcast of this app is already under way.
  • app_email_preview reads the permission as a send to "me" does, on your newest valid version.
  • app_logs with source: "emails" lists the emails of the last 30 days: when, to which user_ref, which kind, sent or why not, and each complaint.

A line per email for 30 days: the application, the person, the kind, the date and what became of it. That is what the limits, the complaints and app_logs read. Never the content nor the address: the content stays 45 days with Postmark, Apigami’s email provider, visible to Apigami only.