Documentation/Building Your App/Email & Notifications
Email & Notifications
Send transactional email through SendGrid or your own SMTP server, push in-app notifications through their own queued → sent → seen lifecycle, and wire both into workflows so a data change can email, alert, and acknowledge on its own.
Overview
Supero gives you two managed channels for reaching people, plus a third for reaching your own team's tools. Transactional email (service_id email for SendGrid, or email_smtp for any SMTP server) sends a real message to an inbox. In-app notifications (service_id notification) are queued → sent → seen records that live inside your app's own UI and never leave it. Chat/SMS channels (slack, sms, whatsapp) are separate managed-services-catalog entries with their own field contracts, covered on the Managed Integrations & Provider Catalog page — this page focuses on email and in-app notifications, and shows where the three meet in a workflow.
Every one of these goes through the same wire primitive once its service is imported and configured for your project:
bash
curl -X POST https://api.supero.dev/api/v1/services/execute \
-H "X-API-Key: $SUPERO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"service_id": "email",
"operation": "send_email",
"input": {
"to_email": "[email protected]",
"subject": "Your order shipped",
"body_html": "<p>Order #4471 is on its way.</p>"
},
"domain": "acme-corp",
"project_uuid": "8f2a1c40-..."
}'ℹ️ Import before you execute
A service has to be imported and configured for your project before /services/execute will call it — see Importing & Configuring the Email Service below. GET https://api.supero.dev/api/v1/services/catalog lists everything available and what's already imported.
The Delivery Services
Pick the channel by where the message needs to land, not by habit. All six service_ids below share the same import → configure → execute lifecycle; what differs is the destination and the field contract.
| service_id | Lands where | Recipient identity | Typical use |
|---|---|---|---|
| An external inbox, via SendGrid. | to_email address | Receipts, invoices, welcome mail, password resets, marketing-style transactional copy — with open/click tracking and bounce handling. | |
| email_smtp | An external inbox, via any SMTP server you control (Gmail, Outlook/365, AWS SES, a self-hosted relay). | to_email address | Same as email, when you want to send from your own domain/mail server instead of SendGrid. |
| notification | Inside your app's own UI — a bell icon, an inbox panel. | A recipient reference on your own extending schema | Real-time in-app alerts that shouldn't leave the app: “your ticket was updated,” “new comment on your order.” |
| sms | A phone, as a text message, via Twilio. | E.164 phone number | Time-sensitive, short-form alerts — shipment updates, appointment reminders. |
| WhatsApp, via Twilio. | Phone number | Same use case as sms, with richer templates and media. | |
| slack | A Slack workspace channel or DM. | Slack channel/user id | Internal team notifications — not customer-facing. |
💡 Full provider catalog
Config fields, sandbox credentials, and the rest of the ~38-provider managed catalog (including sms, whatsapp, and slack) are documented on the Managed Integrations & Provider Catalog page. This page goes deep on email and notification specifically.
Importing & Configuring the Email Service
email (SendGrid) and email_smtp (any SMTP host) ship nearly identical schemas and the same send_email operation — they differ in provider and config_schema. Import whichever one matches how you want to send, then configure it with PUT before your first send.
email (SendGrid)
bash
curl -X POST https://api.supero.dev/api/v1/services/import \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"service_id": "email",
"domain": "acme-corp",
"project_uuid": "8f2a1c40-...",
"config_data": {
"sendgrid_api_key": "SG.xxxxxxxxxxxx",
"default_from_email": "[email protected]",
"default_from_name": "Acme Corp"
}
}'| Config field | Required | Default | Notes |
|---|---|---|---|
| sendgrid_api_key | yes | — | Stored encrypted, returned masked on read. |
| default_from_email | yes | — | Used whenever a send omits from_email. |
| default_from_name | yes | — | Used whenever a send omits from_name. |
| default_reply_to | no | — | Used whenever a send omits reply_to. |
| base_url | yes | — | Your own API host, used to build the SendGrid event-webhook callback URL — https://api.supero.dev on Supero Cloud, your own API host if self-hosted. |
| sendgrid_webhook_signing_key | no | — | Verifies inbound SendGrid Event Webhook calls; set it to enable delivery/engagement/bounce tracking (see Deliverability below). |
| track_opens | no | true | Enables SendGrid open tracking. |
| track_clicks | no | true | Enables SendGrid click tracking. |
| sandbox_mode | no | false | Validates the request and returns success without actually delivering — good for staging. |
| daily_send_limit | no | 10000 | A soft cap you set to match your SendGrid plan. |
| bounce_threshold | no | 3 | Soft bounces to the same address before it is auto-suppressed. |
email_smtp instead
bash
curl -X POST https://api.supero.dev/api/v1/services/import \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"service_id": "email_smtp",
"domain": "acme-corp",
"project_uuid": "8f2a1c40-...",
"config_data": {
"smtp_host": "smtp.gmail.com",
"smtp_username": "[email protected]",
"smtp_password": "an-app-specific-password",
"default_from_email": "[email protected]"
}
}'| Config field | Required | Default | Notes |
|---|---|---|---|
| smtp_host | yes | — | e.g. smtp.gmail.com, smtp.office365.com, email-smtp.{region}.amazonaws.com. |
| smtp_port | no | 587 | 587 for STARTTLS, 465 for implicit SSL, 25 unencrypted. |
| smtp_username | yes | — | Usually the sending mailbox address. |
| smtp_password | yes | — | Stored encrypted — an app-specific password for Gmail/365, not your account password. |
| smtp_use_tls | no | true | STARTTLS, recommended for port 587. |
| smtp_use_ssl | no | false | Implicit SSL, for port 465. |
| default_from_email | yes | — | Used whenever a send omits from_email. |
| default_from_name | no | — | Used whenever a send omits from_name. |
| default_reply_to | no | — | Used whenever a send omits reply_to. |
| base_url | yes | — | Your own API host (SMTP has no webhook callback, but the field is still required). |
Update either service's credentials at any time with PUT /api/v1/services/{service_id}/config — config_data is merged into what's already stored, so send only the fields you're changing.
💡 Test credentials without spending real send quota
A free SendGrid API key plus sandbox_mode: true validates every request and returns success without delivering anything. For email_smtp, a throwaway SMTP test-inbox provider mints disposable credentials in seconds. See the Managed Integrations & Provider Catalog page's sandbox-credentials table for both.
send_email Field Reference
Both email and email_smtp expose the same operation_id, send_email, with an almost identical field contract — write your call once and it works against either provider.
| Field | Required | Notes |
|---|---|---|
| to_email | yes | Single recipient address. |
| to_name | no | Recipient display name. |
| subject | yes | — |
| body_html | at least one of body_html/body_text | HTML body. |
| body_text | at least one of body_html/body_text | Plain-text body. |
| cc | no | Comma-separated addresses. |
| bcc | no | Comma-separated addresses. |
| from_email | no | Per-send override of the configured default_from_email. |
| from_name | no | Per-send override of the configured default_from_name. |
| reply_to | no | Per-send override of the configured default_reply_to. |
| priority | no | high, normal, or low — mapped to an X-Priority email header. |
email (SendGrid) accepts three additional fields that email_smtp does not:
| Field | Notes |
|---|---|
| tags | Comma-separated; becomes up to 10 SendGrid categories. |
| scheduled_at | ISO-8601 timestamp — SendGrid holds the send until then instead of sending immediately. |
| message_type | transactional (default) or marketing — stamped onto the tracking record described below. |
json
{
"service_id": "email",
"operation": "send_email",
"input": {
"to_email": "[email protected]",
"to_name": "Jamie Rivera",
"subject": "Your order shipped",
"body_html": "<p>Order #4471 is on its way — tracking attached.</p>",
"reply_to": "[email protected]",
"priority": "high"
},
"domain": "acme-corp",
"project_uuid": "8f2a1c40-..."
}json
{
"success": true,
"service_id": "email",
"operation": "send_email",
"output": {
"record_uuid": "9c1d...",
"provider_message_id": "abc123.filterdrecv-xyz",
"status": "sent"
},
"side_effects": [],
"record_uuid": "9c1d...",
"error": null
}ℹ️ Where sent_at goes
record_uuid, provider_message_id, and status are what the execute call itself returns. Behind the scenes, every send_email call also creates a tracking record — email:message or email_smtp:message — and a successful send stamps status: sent and sent_at onto that record. Query it afterward the same way you'd query any other CRUD type (see Managing Data) if you need the timestamp, or the fuller delivery history covered under Deliverability below.
From Python and JavaScript
python
from supero.service_lib import ServiceLib
sl = ServiceLib(api_key='ak_...', domain='acme-corp', project_uuid='8f2a1c40-...')
result = sl.email.send(
'[email protected]',
'Your order shipped',
body_html='<p>Order #4471 is on its way.</p>',
)
# raw escape hatch — works for email_smtp too
result = sl.exec('email_smtp', 'send_email', {'to_email': '[email protected]', 'subject': '...', 'body_text': '...'})javascript
await client.transactional.execute('email', 'send_email', {
to_email: '[email protected]',
subject: 'Your order shipped',
body_html: '<p>Order #4471 is on its way.</p>',
});Three Ways to Send an Email
1. Direct call
Call /services/execute yourself — from a backend job, a script, or a button handler — exactly as shown above. This is the right choice when the send needs to happen synchronously as part of a request you're already handling.
2. Inside a workflow step
A service_call step targeting service: "email" lets a workflow send mail as one step in a larger chain — typically fired automatically by an event binding rather than called directly:
json
{
"id": "email_notify",
"type": "service_call",
"service": "email",
"operation": "send_email",
"on_error": "continue",
"input_map": {
"to_email": "{{input.email}}",
"subject": "Welcome to Acme Corp",
"body_html": "<p>Hi {{input.name}}, your account is ready.</p>"
}
}See the Workflows & Automation page for the full step syntax, event bindings, and how to check a run afterward.
3. Platform-managed verification & password-reset mail
Separately from anything you configure, the platform sends its own system email for account flows — a password-reset one-time code through POST /api/v1/auth/forgot-password, verified via POST /api/v1/auth/verify-reset-otp. This channel needs no service import and no credentials from you; it's built into login/signup itself. See the Users & Roles page for the exact request and response shapes.
ℹ️ Not the same thing as auth_otp
The auth_otp catalog integration (send_otp / verify_otp) is a separate, opt-in service for step-up verification inside your own app's flows — confirming a risky action, verifying a phone number — and it does require its own provider credentials (otp_provider_api_key) once imported. The platform's built-in signup/password-reset codes are a different, always-on system channel that needs no configuration at all.
In-App Notifications
notification is an internal service — its operations mutate your own extending-schema record directly instead of calling out to a provider, the same pattern as cart or task. You extend notification:base_notification with a reference to whoever should see it, plus any source-record reference you want to carry along.
json
{
"name": "order_notification",
"namespace": "acmestore",
"parent_type": "tenant",
"extends": "notification:base_notification",
"references": [
{ "name": "recipient", "type": "customer", "cardinality": "one" },
{ "name": "order", "type": "order", "cardinality": "one" }
]
}| base_notification field | Notes |
|---|---|
| status | Required, defaults to queued. Allowed values: queued, sent, seen, dismissed, archived, failed. |
| title | Required. |
| body | Optional longer text. |
| channel | Free-form — how you tag which UI surface should render it, e.g. "bell", "toast". |
| priority | Free-form, your own convention. |
| action_url | Where clicking the notification should take the user. |
| icon | Icon identifier for your UI. |
| sent_at / seen_at / dismissed_at / archived_at | Stamped by the corresponding operation below. |
| expires_at | Optional — your UI decides what to do with it. |
| metadata | Free-form JSON payload. |
| Operation | Transition | Notes |
|---|---|---|
| send_notification | queued → sent | Call after your own in-app dispatch (websocket push, poll response, etc.) succeeds. Stamps sent_at. |
| mark_seen | sent → seen | Idempotent — calling it again once already seen/dismissed/archived just returns the current state with reason: "already_seen", no error. |
| dismiss_notification | sent or seen → dismissed | User closed a toast or swiped it away. |
| archive_notification | any non-terminal status → archived | Removes it from the active feed. |
| mark_all_seen | bulk: every sent record matching a recipient field → seen | Takes recipient_field (the reference field name on your extending schema, e.g. "recipient_uuid") and recipient_uuid. |
A notification record starts life as an ordinary CRUD create — status defaults to queued, no operation call needed yet:
bash
curl -X POST https://api.supero.dev/api/v1/crud/acme-corp/acmestore:order_notification \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "order-4471-shipped",
"title": "Your order shipped",
"body": "Order #4471 is on its way.",
"recipient": "customer-uuid-here",
"order": "order-uuid-here"
}'Then move it through its lifecycle with /services/execute — every operation needs notification_uuid and extending_schema:
json
{
"service_id": "notification",
"operation": "send_notification",
"input": {
"notification_uuid": "<uuid from the create above>",
"extending_schema": "acmestore:order_notification"
},
"domain": "acme-corp",
"project_uuid": "8f2a1c40-..."
}⚠️ Always pass extending_schema
notification's operations have no crud_object_type — they read and write whatever record extending_schema points at, so an operation call without it (or with the wrong one) fails rather than guessing.
Contact Forms That Trigger an Email Workflow
A public contact form is just a schema record plus an event binding — no special “form” concept exists on the platform. Define a plain schema for the submission, render the form on a public page, and let an @create event fire a workflow that emails your team and acknowledges the sender.
1. Define the schema
json
{
"name": "contact_message",
"namespace": "acmestore",
"parent_type": "tenant",
"attributes": [
{ "name": "sender_name", "type": "string", "mandatory": true },
{ "name": "sender_email", "type": "string", "mandatory": true },
{ "name": "message", "type": "string", "mandatory": true },
{ "name": "status", "type": "string", "values": ["new", "acknowledged", "resolved"], "default-value": "new" }
]
}2. Put the form on a public page
Render it on a live_app_public page (see Dynamic UI — the public, unauthenticated storefront/landing surface). Submitting calls client.createObject("contact_message", {...}) exactly like any other write in your app. If the role that reaches this page is already permitted to create records, this works as-is. If you've locked entity access down for that role (default_access: none), grant create explicitly:
json
// access_rule, parent = an access_policy for that role
{
"entity": "contact_message",
"can_create": true,
"can_read": false,
"can_update": false,
"can_delete": false
}See Access Policies (Entity, Record & Field) for the full access_policy / access_rule model.
3. Bind an @create event to a workflow
python
{
"workflow_id": "contact_form_notify",
"display_name": "Contact Form: Notify + Acknowledge",
"status": "Active", "enabled": True, "on_error": "continue",
"input_schema": {
"sender_name": {"type": "string", "required": True},
"sender_email": {"type": "string", "required": True},
"message": {"type": "string", "required": True},
},
"steps": [
{"id": "notify_team", "type": "service_call", "service": "email", "operation": "send_email",
"on_error": "continue",
"input_map": {
"to_email": "[email protected]",
"subject": "New contact form submission",
"body_html": "<p>{{input.sender_name}} ({{input.sender_email}}) wrote:</p><p>{{input.message}}</p>"}},
{"id": "acknowledge_sender", "type": "service_call", "service": "email", "operation": "send_email",
"on_error": "continue",
"input_map": {
"to_email": "{{input.sender_email}}",
"subject": "We got your message",
"body_html": "<p>Thanks {{input.sender_name}} — we will get back to you soon.</p>"}},
],
}json
{
"event": "@create:acmestore:contact_message",
"workflow_id": "contact_form_notify",
"input_map": {
"sender_name": "sender_name",
"sender_email": "sender_email",
"message": "message"
}
}💡 input_map reads the new record itself
For an @create event, the payload the input_map paths resolve against is the record that was just created — so a bare field name like sender_email is that field on the new contact_message row. Full syntax, nesting, and reference hydration are on the Workflows & Automation page.
Deliverability Basics You Own
Supero routes the send; the provider decides whether it lands in an inbox or a spam folder. A few things are on you to get right, mostly relevant to email (SendGrid) rather than email_smtp.
- •Verified sender/domain — default_from_email must be an address or domain you've verified directly with SendGrid (or, for email_smtp, one your SMTP host is actually authorized to send as). This verification happens with the provider, not through a Supero endpoint.
- •sandbox_mode — set it true on the email service while you're building; every send validates and returns success without actually delivering. Turn it off before launch.
- •daily_send_limit and bounce_threshold — soft caps you configure to match your SendGrid plan's real limits; bounce_threshold controls how many soft bounces to the same address trigger an auto-suppress.
- •Suppression list — bounces, unsubscribes, and spam reports land in the email:suppression schema; check it before a send if you're doing your own bulk mailing logic.
bash
curl -X POST https://api.supero.dev/api/v1/services/execute \
-H "X-API-Key: $SUPERO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"service_id": "email",
"operation": "check_suppression",
"input": {"email_address": "[email protected]"},
"domain": "acme-corp",
"project_uuid": "8f2a1c40-..."
}'json
{
"success": true,
"service_id": "email",
"operation": "check_suppression",
"output": {
"is_suppressed": false,
"reason": "",
"suppressed_at": "",
"can_retry": true
}
}| email:suppression field | Notes |
|---|---|
| email_address | Required. |
| reason | Required — bounce, unsubscribe, spam_report, etc. |
| bounce_type / bounce_code / bounce_description | Populated for bounce-sourced suppressions. |
| source_message_uuid / source_campaign_uuid | Traces back to what triggered the suppression. |
| suppressed_at | Required. |
| expires_at | Optional — some suppression reasons are temporary. |
Set sendgrid_webhook_signing_key on the email service's config to let SendGrid's Event Webhook keep delivered_at, opened_at, clicked_at, and bounced_at current on your email:message records as events arrive, and get_delivery_stats gives you an aggregate rollup (total_sent, delivered, opened, clicked, bounced, failed, plus rates) via the same /services/execute call.
⚠️ email_smtp has none of this
A raw SMTP connection has no return channel for opens, clicks, or bounce notifications — you only get sent_at and error_message on the email_smtp:message record. If delivery/engagement tracking and an auto-managed suppression list matter to you, use email (SendGrid) instead.
Next steps
- •Workflows & Automation — the full step syntax, event bindings, and how to check a run’s status.
- •Managed Integrations & Provider Catalog — the full ~38-provider catalog, including sms, whatsapp, and slack, plus sandbox credentials for each.
- •Transactional Services — the internal services catalog notification belongs to, and the shared extends + state-machine model.
- •Access Policies (Entity, Record & Field) — the entity/record/field access model referenced in Contact Forms above.
On this page