Emails to your users
MarkdownYour 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.
{ "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": { "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.
Before the first email
Section titled “Before the first email”-
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.
-
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.
-
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
emailsreceive your service emails as well; only your news wait for someone to tick their box.
Who sends, and to whom
Section titled “Who sends, and to whom”| 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.
// 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 })}Tell your team: a public form
Section titled “Tell your team: a public form”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.
// 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": {} }.
The two kinds
Section titled “The two kinds”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.
The blocks
Section titled “The blocks”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 |
{ "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.
The footer, and how a person stops
Section titled “The footer, and how a person stops”| 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.
What protects the people you write to
Section titled “What protects the people you write to”- The recipient is always a person connected to your application, whose address is verified, and who
ticked your news box for a
marketingemail. - 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
marketingemail 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.
Refusals
Section titled “Refusals”| 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.
From your AI
Section titled “From your AI”Connected to Apigami’s MCP server, a member of your team’s AI has three tools:
app_email_previewrenders{ 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_sendsends it,toauser_ref, to"me"to see it in a real mailbox, or, for amarketingemail, to"all_marketing": everyone who accepted your news.- To a
user_refor to"all_marketing", the permission is read on your production version, as for your API key, and formarketingthe 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, withoutconfirm, sends nothing and answers how many people the news would reach: everyone who accepted it, before anyone is held back. The second, withconfirmset 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.
- To a
app_email_previewreads the permission as a send to"me"does, on your newest valid version.app_logswithsource: "emails"lists the emails of the last 30 days: when, to whichuser_ref, which kind, sent or why not, and each complaint.
What Apigami keeps
Section titled “What Apigami keeps”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.